@@ -38,6 +38,9 @@ const GLOBAL_FILES = ['CLAUDE.CCW.md'];
3838// Files that should be excluded from cleanup (user-specific settings)
3939const EXCLUDED_FILES = [ 'settings.json' , 'settings.local.json' , 'CLAUDE.md' ] ;
4040
41+ // CCW marker in CLAUDE.md for tracking ccw-added content
42+ const CCW_REFERENCE_LINE = '- **CCW Instructions**: @~/.claude/CLAUDE.CCW.md' ;
43+
4144interface InstallOptions {
4245 mode ?: string ;
4346 path ?: string ;
@@ -189,6 +192,112 @@ function restoreDisabledState(
189192 return { skillsRestored, commandsRestored } ;
190193}
191194
195+ /**
196+ * Backup existing files that will be replaced during installation
197+ * Works without manifest - scans target directories for existing content
198+ * @param installPath - Target installation path
199+ * @param availableDirs - Directories that will be installed
200+ * @param globalPath - Global home path (for global subdirs in Path mode)
201+ * @returns Backup directory path or null if nothing to backup
202+ */
203+ async function backupBeforeInstall (
204+ installPath : string ,
205+ availableDirs : string [ ] ,
206+ globalPath ?: string
207+ ) : Promise < string | null > {
208+ const spinner = createSpinner ( 'Creating pre-install backup...' ) . start ( ) ;
209+
210+ try {
211+ const timestamp = new Date ( ) . toISOString ( ) . replace ( / [ - : ] / g, '' ) . replace ( 'T' , '-' ) . split ( '.' ) [ 0 ] ;
212+ const backupDir = join ( installPath , `.ccw-backup-${ timestamp } ` ) ;
213+ let backedUp = 0 ;
214+
215+ // Backup directories that will be overwritten
216+ for ( const dir of availableDirs ) {
217+ const targetDir = join ( installPath , dir ) ;
218+ if ( ! existsSync ( targetDir ) ) continue ;
219+
220+ const backupTarget = join ( backupDir , dir ) ;
221+ const result = await backupDirectoryRecursive ( targetDir , backupTarget , spinner ) ;
222+ backedUp += result ;
223+ }
224+
225+ // Backup global subdirs if Path mode
226+ if ( globalPath ) {
227+ for ( const subdir of GLOBAL_SUBDIRS ) {
228+ const globalSubdir = join ( globalPath , '.claude' , subdir ) ;
229+ if ( ! existsSync ( globalSubdir ) ) continue ;
230+
231+ const backupTarget = join ( backupDir , '.claude-global' , subdir ) ;
232+ const result = await backupDirectoryRecursive ( globalSubdir , backupTarget , spinner ) ;
233+ backedUp += result ;
234+ }
235+
236+ // Backup global CLAUDE.CCW.md
237+ const globalCcwMd = join ( globalPath , '.claude' , 'CLAUDE.CCW.md' ) ;
238+ if ( existsSync ( globalCcwMd ) ) {
239+ const backupTarget = join ( backupDir , '.claude-global' , 'CLAUDE.CCW.md' ) ;
240+ const backupFileDir = dirname ( backupTarget ) ;
241+ if ( ! existsSync ( backupFileDir ) ) mkdirSync ( backupFileDir , { recursive : true } ) ;
242+ copyFileSync ( globalCcwMd , backupTarget ) ;
243+ backedUp ++ ;
244+ }
245+ }
246+
247+ if ( backedUp > 0 ) {
248+ spinner . succeed ( `Pre-install backup: ${ backupDir } (${ backedUp } files)` ) ;
249+ return backupDir ;
250+ } else {
251+ spinner . info ( 'No existing files to backup' ) ;
252+ // Remove empty backup dir
253+ try { rmdirSync ( backupDir ) ; } catch { /* ignore */ }
254+ return null ;
255+ }
256+ } catch ( err ) {
257+ const errMsg = err as Error ;
258+ spinner . warn ( `Backup warning: ${ errMsg . message } ` ) ;
259+ return null ;
260+ }
261+ }
262+
263+ /**
264+ * Recursively backup a directory
265+ */
266+ async function backupDirectoryRecursive (
267+ src : string ,
268+ dest : string ,
269+ spinner : Ora
270+ ) : Promise < number > {
271+ let count = 0 ;
272+
273+ if ( ! existsSync ( src ) ) return count ;
274+
275+ if ( ! existsSync ( dest ) ) {
276+ mkdirSync ( dest , { recursive : true } ) ;
277+ }
278+
279+ const entries = readdirSync ( src ) ;
280+ for ( const entry of entries ) {
281+ const srcPath = join ( src , entry ) ;
282+ const destPath = join ( dest , entry ) ;
283+ const stat = statSync ( srcPath ) ;
284+
285+ if ( stat . isDirectory ( ) ) {
286+ count += await backupDirectoryRecursive ( srcPath , destPath , spinner ) ;
287+ } else {
288+ try {
289+ spinner . text = `Backing up: ${ entry } ` ;
290+ copyFileSync ( srcPath , destPath ) ;
291+ count ++ ;
292+ } catch {
293+ // Ignore individual file errors
294+ }
295+ }
296+ }
297+
298+ return count ;
299+ }
300+
192301// Get package root directory (ccw/src/commands -> ccw)
193302function getPackageRoot ( ) : string {
194303 return join ( __dirname , '..' , '..' ) ;
@@ -308,33 +417,61 @@ export async function installCommand(options: InstallOptions): Promise<void> {
308417 console . log ( '' ) ;
309418 info ( `Found ${ availableDirs . length } directories to install: ${ availableDirs . join ( ', ' ) } ` ) ;
310419
311- // Show what will be installed including .codex subdirectories
420+ // Interactive .codex subdirectory selection
421+ let codexExcludeDirs : string [ ] = [ ] ;
312422 if ( availableDirs . includes ( '.codex' ) ) {
313423 const codexPath = join ( sourceDir , '.codex' ) ;
424+ const codexSubdirs : Array < { name : string ; count : number ; label : string } > = [ ] ;
314425
315- // Show prompts info
426+ // Scan available codex subdirectories
316427 const promptsPath = join ( codexPath , 'prompts' ) ;
317428 if ( existsSync ( promptsPath ) ) {
318429 const promptFiles = readdirSync ( promptsPath , { recursive : true } ) . filter ( f =>
319430 statSync ( join ( promptsPath , f . toString ( ) ) ) . isFile ( )
320431 ) ;
321- info ( ` └─ .codex/ prompts: ${ promptFiles . length } files` ) ;
432+ codexSubdirs . push ( { name : ' prompts' , count : promptFiles . length , label : ' files' } ) ;
322433 }
323434
324- // Show agents info
325435 const agentsPath = join ( codexPath , 'agents' ) ;
326436 if ( existsSync ( agentsPath ) ) {
327437 const agentFiles = readdirSync ( agentsPath ) . filter ( f => f . endsWith ( '.md' ) ) ;
328- info ( ` └─ .codex/ agents: ${ agentFiles . length } agent definitions` ) ;
438+ codexSubdirs . push ( { name : ' agents' , count : agentFiles . length , label : ' agent definitions' } ) ;
329439 }
330440
331- // Show skills info
332441 const skillsPath = join ( codexPath , 'skills' ) ;
333442 if ( existsSync ( skillsPath ) ) {
334443 const skillDirs = readdirSync ( skillsPath ) . filter ( f =>
335444 statSync ( join ( skillsPath , f ) ) . isDirectory ( )
336445 ) ;
337- info ( ` └─ .codex/skills: ${ skillDirs . length } skills` ) ;
446+ codexSubdirs . push ( { name : 'skills' , count : skillDirs . length , label : 'skills' } ) ;
447+ }
448+
449+ if ( codexSubdirs . length > 0 ) {
450+ info ( 'Codex components available:' ) ;
451+ for ( const sub of codexSubdirs ) {
452+ info ( ` └─ .codex/${ sub . name } : ${ sub . count } ${ sub . label } ` ) ;
453+ }
454+
455+ // Let user select which codex subdirs to install
456+ const { selectedCodexDirs } = await inquirer . prompt ( [ {
457+ type : 'checkbox' ,
458+ name : 'selectedCodexDirs' ,
459+ message : 'Select .codex components to install:' ,
460+ choices : codexSubdirs . map ( sub => ( {
461+ name : ` ${ chalk . cyan ( sub . name ) } — ${ sub . count } ${ sub . label } ` ,
462+ value : sub . name ,
463+ checked : sub . name !== 'agents' , // agents unchecked by default
464+ } ) ) ,
465+ } ] ) ;
466+
467+ // Build exclude list from unselected dirs
468+ codexExcludeDirs = codexSubdirs
469+ . map ( s => s . name )
470+ . filter ( name => ! ( selectedCodexDirs as string [ ] ) . includes ( name ) ) ;
471+
472+ if ( codexExcludeDirs . length > 0 ) {
473+ info ( `Skipping .codex components: ${ codexExcludeDirs . join ( ', ' ) } ` ) ;
474+ }
338475 }
339476 }
340477
@@ -404,28 +541,37 @@ export async function installCommand(options: InstallOptions): Promise<void> {
404541
405542 divider ( ) ;
406543
407- // Check for existing installation manifest
408- const existingManifest = findManifest ( installPath , mode ) ;
409- let cleanStats = { removed : 0 , skipped : 0 } ;
410-
411- if ( existingManifest ) {
412- // Has manifest - clean based on manifest records
413- warning ( 'Existing installation found at this location' ) ;
414- info ( ` Files in manifest: ${ existingManifest . files ?. length || 0 } ` ) ;
415- info ( ` Installed: ${ new Date ( existingManifest . installation_date ) . toLocaleDateString ( ) } ` ) ;
544+ // Always backup existing content before overwriting
545+ const existingDirsToBackup = availableDirs . filter ( dir => existsSync ( join ( installPath , dir ) ) ) ;
546+ const globalPathForBackup = mode === 'Path' ? homedir ( ) : undefined ;
416547
548+ if ( existingDirsToBackup . length > 0 || ( globalPathForBackup && existsSync ( join ( globalPathForBackup , '.claude' ) ) ) ) {
417549 if ( ! options . force ) {
418- const { backup } = await inquirer . prompt ( [ {
550+ const { doBackup } = await inquirer . prompt ( [ {
419551 type : 'confirm' ,
420- name : 'backup ' ,
421- message : 'Create backup before reinstalling? ' ,
552+ name : 'doBackup ' ,
553+ message : 'Backup existing files before installing? (recommended) ' ,
422554 default : true
423555 } ] ) ;
424556
425- if ( backup ) {
426- await createBackup ( existingManifest ) ;
557+ if ( doBackup ) {
558+ await backupBeforeInstall ( installPath , existingDirsToBackup , globalPathForBackup ) ;
427559 }
560+ } else {
561+ // Force mode: always backup silently
562+ await backupBeforeInstall ( installPath , existingDirsToBackup , globalPathForBackup ) ;
428563 }
564+ }
565+
566+ // Check for existing installation manifest for cleanup
567+ const existingManifest = findManifest ( installPath , mode ) ;
568+ let cleanStats = { removed : 0 , skipped : 0 } ;
569+
570+ if ( existingManifest ) {
571+ // Has manifest - clean based on manifest records
572+ warning ( 'Existing installation found at this location' ) ;
573+ info ( ` Files in manifest: ${ existingManifest . files ?. length || 0 } ` ) ;
574+ info ( ` Installed: ${ new Date ( existingManifest . installation_date ) . toLocaleDateString ( ) } ` ) ;
429575
430576 // Clean based on manifest records
431577 console . log ( '' ) ;
@@ -511,7 +657,13 @@ export async function installCommand(options: InstallOptions): Promise<void> {
511657 spinner . text = `Installing ${ dir } ...` ;
512658
513659 // For Path mode on .claude, exclude global subdirs (they're already installed to global)
514- const excludeDirs = ( mode === 'Path' && dir === '.claude' ) ? GLOBAL_SUBDIRS : [ ] ;
660+ // For .codex, exclude user-deselected subdirs (e.g. agents)
661+ let excludeDirs : string [ ] = [ ] ;
662+ if ( mode === 'Path' && dir === '.claude' ) {
663+ excludeDirs = GLOBAL_SUBDIRS ;
664+ } else if ( dir === '.codex' ) {
665+ excludeDirs = codexExcludeDirs ;
666+ }
515667 const { files, directories } = await copyDirectory ( srcPath , destPath , manifest , excludeDirs ) ;
516668 totalFiles += files ;
517669 totalDirs += directories ;
0 commit comments