diff --git a/CHANGELOG.md b/CHANGELOG.md index cb60fce..d97139e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed +- **Init wizard**: individual feature prompts with explanatory notes replace extras multiselect +- **Init wizard**: scope-aware `.claudeignore` batch install across all discovered projects (user scope) +- **Init wizard**: project discovery via `~/.claude/history.jsonl` to find all Claude-used git repos +- **Init wizard**: managed settings sudo confirmation moved to prompt phase (before spinner) +- **Init wizard**: safe-delete prompt moved to prompt phase for uninterrupted install + +### Added +- `--hud` flag for `devflow init` to explicitly enable HUD +- `discoverProjectGitRoots()` utility for finding projects from Claude history + +### Removed +- Extras multiselect (`buildExtrasOptions`) — replaced by individual feature prompts + --- ## [1.8.0] - 2026-03-22 diff --git a/README.md b/README.md index 10da51e..5d07213 100644 --- a/README.md +++ b/README.md @@ -248,8 +248,8 @@ Session context is saved and restored automatically via Working Memory hooks — | `--teams` / `--no-teams` | Enable/disable Agent Teams (experimental, default: off) | | `--ambient` / `--no-ambient` | Enable/disable ambient mode (default: on) | | `--memory` / `--no-memory` | Enable/disable working memory (default: on) | +| `--hud` / `--no-hud` | Enable/disable HUD status line (default: on) | | `--hud-only` | Install only the HUD (no plugins, hooks, or extras) | -| `--no-hud` | Disable HUD status line | | `--verbose` | Show detailed output | ### HUD Options diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 70ea7e9..7a8161f 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -13,6 +13,7 @@ import { installSettings, installManagedSettings, installClaudeignore, + discoverProjectGitRoots, updateGitignore, createDocsStructure, createMemoryDir, @@ -21,15 +22,15 @@ import { } from '../utils/post-install.js'; import { DEVFLOW_PLUGINS, LEGACY_SKILL_NAMES, LEGACY_COMMAND_NAMES, buildAssetMaps, type PluginDefinition } from '../plugins.js'; import { detectPlatform, detectShell, getProfilePath, getSafeDeleteInfo, hasSafeDelete } from '../utils/safe-delete.js'; -import { generateSafeDeleteBlock, isAlreadyInstalled, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../utils/safe-delete-install.js'; -import { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; -import { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; -import { addHudStatusLine, removeHudStatusLine, hasHudStatusLine } from './hud.js'; +import { generateSafeDeleteBlock, installToProfile, removeFromProfile, getInstalledVersion, SAFE_DELETE_BLOCK_VERSION } from '../utils/safe-delete-install.js'; +import { addAmbientHook } from './ambient.js'; +import { addMemoryHooks, removeMemoryHooks } from './memory.js'; +import { addHudStatusLine, removeHudStatusLine } from './hud.js'; import { loadConfig as loadHudConfig, saveConfig as saveHudConfig } from '../hud/config.js'; import { readManifest, writeManifest, resolvePluginList, detectUpgrade } from '../utils/manifest.js'; // Re-export pure functions for tests (canonical source is post-install.ts) -export { substituteSettingsTemplate, computeGitignoreAppend, applyTeamsConfig, stripTeamsConfig, mergeDenyList } from '../utils/post-install.js'; +export { substituteSettingsTemplate, computeGitignoreAppend, applyTeamsConfig, stripTeamsConfig, mergeDenyList, discoverProjectGitRoots } from '../utils/post-install.js'; export { addAmbientHook, removeAmbientHook, hasAmbientHook } from './ambient.js'; export { addMemoryHooks, removeMemoryHooks, hasMemoryHooks } from './memory.js'; export { addHudStatusLine, removeHudStatusLine, hasHudStatusLine } from './hud.js'; @@ -55,40 +56,6 @@ export function parsePluginSelection( return { selected, invalid }; } -export type ExtraId = 'settings' | 'claudeignore' | 'gitignore' | 'docs' | 'safe-delete'; - -interface ExtraOption { - value: ExtraId; - label: string; - hint: string; -} - -/** - * Build the list of configuration extras available for the given scope/git context. - * Pure function — no I/O, no side effects. - */ -export function buildExtrasOptions(scope: 'user' | 'local', gitRoot: string | null): ExtraOption[] { - const options: ExtraOption[] = [ - { value: 'settings', label: 'Settings & Working Memory', hint: 'Model defaults, session memory hooks, status line' }, - ]; - - if (gitRoot) { - options.push({ value: 'claudeignore', label: '.claudeignore', hint: 'Exclude secrets, deps, build artifacts from Claude context' }); - } - - if (scope === 'local' && gitRoot) { - options.push({ value: 'gitignore', label: '.gitignore entries', hint: 'Add .claude/ and .devflow/ to .gitignore' }); - } - - if (scope === 'local') { - options.push({ value: 'docs', label: '.docs/ directory', hint: 'Review reports, dev logs, status tracking for this project' }); - } - - options.push({ value: 'safe-delete', label: 'Safe-delete (rm → trash)', hint: 'Override rm to use trash CLI — prevents accidental deletion' }); - - return options; -} - /** * Options for the init command parsed by Commander.js */ @@ -114,6 +81,7 @@ export const initCommand = new Command('init') .option('--no-ambient', 'Disable ambient mode') .option('--memory', 'Enable working memory (session context preservation)') .option('--no-memory', 'Disable working memory hooks') + .option('--hud', 'Enable HUD (git info, context usage, session stats)') .option('--no-hud', 'Disable HUD status line') .option('--hud-only', 'Install only the HUD (no plugins, hooks, or extras)') .action(async (options: InitOptions) => { @@ -246,12 +214,30 @@ export const initCommand = new Command('init') process.exit(1); } } else if (process.stdin.isTTY) { + // Short hints to prevent overflow in multiselect — full descriptions live in plugins.ts + const pluginHints: Record = { + 'devflow-specify': 'feature specs → GitHub issues', + 'devflow-implement': 'explore, plan, code, review', + 'devflow-code-review': 'parallel specialized reviewers', + 'devflow-resolve': 'fix review issues by risk', + 'devflow-debug': 'competing hypotheses', + 'devflow-self-review': 'Simplifier + Scrutinizer', + 'devflow-typescript': 'TypeScript patterns', + 'devflow-react': 'React patterns', + 'devflow-accessibility': 'WCAG compliance', + 'devflow-frontend-design': 'typography, color, spacing', + 'devflow-go': 'Go patterns', + 'devflow-java': 'Java patterns', + 'devflow-python': 'Python patterns', + 'devflow-rust': 'Rust patterns', + }; + const choices = DEVFLOW_PLUGINS .filter(pl => pl.name !== 'devflow-core-skills' && pl.name !== 'devflow-ambient' && pl.name !== 'devflow-audit-claude') .map(pl => ({ value: pl.name, label: pl.name.replace('devflow-', ''), - hint: pl.description, + hint: pluginHints[pl.name] ?? pl.description, })); const preSelected = DEVFLOW_PLUGINS @@ -280,11 +266,17 @@ export const initCommand = new Command('init') } else if (!process.stdin.isTTY) { teamsEnabled = false; } else { + p.note( + 'Agent Teams enable peer debate between agents — adversarial\n' + + 'review, competing hypotheses in debugging, and consensus-driven\n' + + 'exploration. Experimental: may be unstable.', + 'Agent Teams', + ); const teamsChoice = await p.select({ message: 'Enable Agent Teams?', options: [ - { value: false, label: 'No (Recommended)', hint: 'Experimental — may be unstable' }, - { value: true, label: 'Yes', hint: 'Advanced — peer debate in review, exploration, debugging' }, + { value: false, label: 'Not yet', hint: 'Recommended' }, + { value: true, label: 'Yes', hint: 'Experimental' }, ], }); if (p.isCancel(teamsChoice)) { @@ -301,11 +293,18 @@ export const initCommand = new Command('init') } else if (!process.stdin.isTTY) { ambientEnabled = true; } else { + p.note( + 'Auto-classifies every prompt by intent and depth. Loads relevant\n' + + 'skills automatically and escalates to full agent pipelines\n' + + '(review, debug, implement) when the task warrants it.\n\n' + + 'Adds a small amount of context to each prompt for classification.', + 'Ambient Mode', + ); const ambientChoice = await p.select({ message: 'Enable ambient mode?', options: [ - { value: true, label: 'Yes (Recommended)', hint: 'Classifies intent, loads skills, orchestrates agents' }, - { value: false, label: 'No', hint: 'Full control — load skills manually' }, + { value: true, label: 'Yes', hint: 'Recommended' }, + { value: false, label: 'No', hint: 'Manual skill loading via slash commands' }, ], }); if (p.isCancel(ambientChoice)) { @@ -322,8 +321,16 @@ export const initCommand = new Command('init') } else if (!process.stdin.isTTY) { memoryEnabled = true; } else { + p.note( + 'Preserves session context across /clear, restarts, and context\n' + + 'compaction. Clear your session at any point and resume right\n' + + 'where you left off.\n\n' + + 'Runs a background agent on session stop that consumes additional\n' + + 'tokens. Consider skipping if token usage is a concern.', + 'Working Memory', + ); const memoryChoice = await p.confirm({ - message: 'Enable working memory? (automatic session context preservation)', + message: 'Enable working memory? (Recommended)', initialValue: true, }); if (p.isCancel(memoryChoice)) { @@ -340,8 +347,13 @@ export const initCommand = new Command('init') } else if (!process.stdin.isTTY) { hudEnabled = true; } else { + p.note( + 'The HUD displays git branch, context usage, and session stats\n' + + 'in the Claude Code status bar. Configurable via devflow hud.', + 'HUD', + ); const hudChoice = await p.confirm({ - message: 'Enable HUD status line?', + message: 'Enable HUD? (Recommended)', initialValue: true, }); if (p.isCancel(hudChoice)) { @@ -351,14 +363,123 @@ export const initCommand = new Command('init') hudEnabled = hudChoice; } - // Security deny list placement (user scope + TTY only) + // .claudeignore prompt (needs early git detection) + const earlyGitRoot = await getGitRoot(); + let claudeignoreEnabled = true; + let discoveredProjects: string[] = []; + if (earlyGitRoot && process.stdin.isTTY) { + if (scope === 'user') { + // User scope: discover all projects and offer batch install + discoveredProjects = await discoverProjectGitRoots(); + p.note( + 'Scans all projects Claude has worked on and creates a\n' + + '.claudeignore in each git repository. Excludes secrets,\n' + + 'API keys, dependencies, and build artifacts from context.', + '.claudeignore', + ); + if (discoveredProjects.length > 0) { + const maxShow = 5; + const projectLines = discoveredProjects.slice(0, maxShow).join('\n'); + const overflow = discoveredProjects.length > maxShow + ? `\n... (${discoveredProjects.length - maxShow} more)` + : ''; + p.note(projectLines + overflow, `Discovered ${discoveredProjects.length} projects`); + const claudeignoreChoice = await p.confirm({ + message: `Install .claudeignore to ${discoveredProjects.length} projects? (Recommended)`, + initialValue: true, + }); + if (p.isCancel(claudeignoreChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + claudeignoreEnabled = claudeignoreChoice; + } else { + // No projects discovered — fall back to current project only + const claudeignoreChoice = await p.confirm({ + message: 'Create .claudeignore? (Recommended)', + initialValue: true, + }); + if (p.isCancel(claudeignoreChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + claudeignoreEnabled = claudeignoreChoice; + } + } else { + // Local scope: single-project note + p.note( + 'Creates a .claudeignore in this project that excludes\n' + + 'secrets, API keys, dependencies, and build artifacts from\n' + + 'Claude\'s context window.', + '.claudeignore', + ); + const claudeignoreChoice = await p.confirm({ + message: 'Create .claudeignore? (Recommended)', + initialValue: true, + }); + if (p.isCancel(claudeignoreChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + claudeignoreEnabled = claudeignoreChoice; + } + } else if (!earlyGitRoot) { + claudeignoreEnabled = false; + } + + // Safe-delete detection + prompt (all detection early, action deferred) + const platform = detectPlatform(); + const shell = detectShell(); + const safeDeleteInfo = getSafeDeleteInfo(platform); + const safeDeleteAvailable = hasSafeDelete(platform); + const profilePath = getProfilePath(shell); + + let safeDeleteAction: 'install' | 'upgrade' | 'skip' = 'skip'; + let safeDeleteBlock: string | null = null; + + if (process.stdin.isTTY && profilePath && safeDeleteAvailable) { + const trashCmd = safeDeleteInfo.command; + safeDeleteBlock = generateSafeDeleteBlock(shell, process.platform, trashCmd); + + if (safeDeleteBlock) { + const installedVersion = await getInstalledVersion(profilePath); + if (installedVersion === SAFE_DELETE_BLOCK_VERSION) { + safeDeleteAction = 'skip'; // already current + } else if (installedVersion > 0) { + safeDeleteAction = 'upgrade'; // auto-upgrade, no prompt needed + } else { + // Fresh install — prompt + p.note( + 'Overrides rm to use your system trash CLI instead of permanent\n' + + 'deletion. Prevents accidental data loss from rm -rf.', + 'Safe Delete', + ); + const safeDeleteConfirm = await p.confirm({ + message: `Install safe-delete to ${profilePath}? (uses ${trashCmd ?? 'recycle bin'})`, + initialValue: true, + }); + + if (!p.isCancel(safeDeleteConfirm) && safeDeleteConfirm) { + safeDeleteAction = 'install'; + } + } + } + } + + // Security deny list placement (user scope + TTY only — last prompt before install) let securityMode: SecurityMode = 'user'; if (scope === 'user' && process.stdin.isTTY) { + p.note( + 'DevFlow includes a security deny list that blocks dangerous\n' + + 'commands (rm -rf, sudo, eval, etc). It can be installed as a\n' + + 'read-only system file or in your editable settings.json.', + 'Security Deny List', + ); const securityChoice = await p.select({ - message: 'How should DevFlow install the security deny list?', + message: 'How should DevFlow install the deny list?', options: [ - { value: 'managed', label: 'Managed settings (Recommended)', hint: 'Cannot be overridden, requires admin' }, - { value: 'user', label: 'User settings', hint: 'Included in settings.json, editable' }, + { value: 'managed', label: 'Managed settings', hint: 'Recommended — read-only, cannot be overridden' }, + { value: 'user', label: 'User settings', hint: 'Editable in settings.json' }, ], }); @@ -370,7 +491,40 @@ export const initCommand = new Command('init') securityMode = securityChoice as SecurityMode; } - // Start spinner immediately after prompts — covers path resolution + git detection + // Managed settings sudo confirmation (last interactive step — may prompt for password) + let managedSettingsConfirmed = false; + if (securityMode === 'managed') { + p.note( + 'This writes a read-only security deny list to a system directory\n' + + 'and may prompt for your password (sudo).\n\n' + + 'Not sure about this? Paste this into another Claude Code session:\n\n' + + ' "I\'m installing DevFlow and it wants to write a\n' + + ' managed-settings.json file using sudo. Review the source\n' + + ' at https://github.com/dean0x/devflow and tell me if\n' + + ' it\'s safe."', + 'Managed Settings', + ); + + const sudoChoice = await p.select({ + message: 'Continue with managed settings?', + options: [ + { value: 'yes', label: 'Yes, continue', hint: 'May prompt for your password' }, + { value: 'no', label: 'No, fall back to settings.json', hint: 'Editable user settings instead' }, + ], + }); + + if (p.isCancel(sudoChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + + managedSettingsConfirmed = sudoChoice === 'yes'; + } + + // ╭──────────────────────────────────────────────────────────╮ + // │ All prompts collected — installation begins │ + // ╰──────────────────────────────────────────────────────────╯ + const s = p.spinner(); s.start('Resolving paths'); @@ -383,7 +537,7 @@ export const initCommand = new Command('init') const paths = await getInstallationPaths(scope); claudeDir = paths.claudeDir; devflowDir = paths.devflowDir; - gitRoot = paths.gitRoot ?? await getGitRoot(); + gitRoot = paths.gitRoot ?? earlyGitRoot; } catch (error) { s.stop('Path resolution failed'); p.log.error(`Path configuration error: ${error instanceof Error ? error.message : error}`); @@ -473,9 +627,8 @@ export const initCommand = new Command('init') } } - s.stop('Plugins installed'); - // Clean up stale skills from previous installations + s.message('Cleaning up'); const skillsDir = path.join(claudeDir, 'skills'); let staleRemoved = 0; for (const legacy of LEGACY_SKILL_NAMES) { @@ -509,145 +662,102 @@ export const initCommand = new Command('init') p.log.info(`Cleaned up ${staleCommandsRemoved} legacy command(s)`); } - // === Configuration extras === - const extrasOptions = buildExtrasOptions(scope, gitRoot); - let selectedExtras: ExtraId[]; - - if (process.stdin.isTTY) { - const extrasSelection = await p.multiselect({ - message: 'Configure extras', - options: extrasOptions, - initialValues: extrasOptions.map(o => o.value), - required: false, - }); + // === Settings & hooks (all automatic based on collected choices) === + s.message('Configuring settings'); - if (p.isCancel(extrasSelection)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } - - selectedExtras = extrasSelection as ExtraId[]; - } else { - selectedExtras = extrasOptions.map(o => o.value); + // Determine effective security mode (managed settings executed later, after safe-delete) + let effectiveSecurityMode = securityMode; + if (securityMode === 'managed' && !managedSettingsConfirmed) { + effectiveSecurityMode = 'user'; } + await installSettings(claudeDir, rootDir, devflowDir, verbose, teamsEnabled, effectiveSecurityMode); - // Settings may trigger its own TTY sub-prompt — run outside spinner - if (selectedExtras.includes('settings')) { - // Attempt managed settings write if user chose managed mode - let effectiveSecurityMode = securityMode; - if (securityMode === 'managed') { - p.note( - 'This writes a read-only security deny list to a system directory\n' + - 'and may prompt for your password (sudo).\n\n' + - 'Not sure about this? Paste this into another Claude Code session:\n\n' + - ' "I\'m installing DevFlow and it wants to write a\n' + - ' managed-settings.json file using sudo. Review the source\n' + - ' at https://github.com/dean0x/devflow and tell me if\n' + - ' it\'s safe."', - 'Managed Settings', - ); - - const sudoChoice = await p.select({ - message: 'Continue with managed settings?', - options: [ - { value: 'yes', label: 'Yes, continue', hint: 'May prompt for your password' }, - { value: 'no', label: 'No, fall back to settings.json', hint: 'Deny list stored in editable user settings instead' }, - ], - }); - - if (p.isCancel(sudoChoice)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } + const settingsPath = path.join(claudeDir, 'settings.json'); - if (sudoChoice === 'yes') { - const managed = await installManagedSettings(rootDir, verbose); - if (!managed) { - p.log.warn('Managed settings write failed — falling back to user settings'); - effectiveSecurityMode = 'user'; - } - } else { - effectiveSecurityMode = 'user'; - } - } - await installSettings(claudeDir, rootDir, devflowDir, verbose, teamsEnabled, effectiveSecurityMode); + // Configure ambient hook, memory hooks, and HUD statusLine in a single read-modify-write pass + try { + let content = await fs.readFile(settingsPath, 'utf-8'); + const original = content; - // Install ambient hook if enabled + // Ambient hook if (ambientEnabled) { - const settingsPath = path.join(claudeDir, 'settings.json'); - try { - const content = await fs.readFile(settingsPath, 'utf-8'); - const updated = addAmbientHook(content, devflowDir); - if (updated !== content) { - await fs.writeFile(settingsPath, updated, 'utf-8'); - if (verbose) { - p.log.success('Ambient mode hook installed'); - } - } - } catch { /* settings.json may not exist yet */ } + content = addAmbientHook(content, devflowDir); } - // Manage memory hooks based on user choice - const settingsPath = path.join(claudeDir, 'settings.json'); - try { - const content = await fs.readFile(settingsPath, 'utf-8'); - // Always remove-then-add to upgrade hook format (e.g., .sh → run-hook) - const cleaned = removeMemoryHooks(content); - const updated = memoryEnabled - ? addMemoryHooks(cleaned, devflowDir) - : cleaned; - if (updated !== content) { - await fs.writeFile(settingsPath, updated, 'utf-8'); - if (verbose) { - p.log.info(`Working memory ${memoryEnabled ? 'enabled' : 'disabled'}`); - } + // Memory hooks — always remove-then-add to upgrade hook format (e.g., .sh → run-hook) + const cleaned = removeMemoryHooks(content); + content = memoryEnabled ? addMemoryHooks(cleaned, devflowDir) : cleaned; + + // HUD statusLine + content = hudEnabled + ? addHudStatusLine(content, devflowDir) + : removeHudStatusLine(content); + + if (content !== original) { + await fs.writeFile(settingsPath, content, 'utf-8'); + if (verbose) { + if (ambientEnabled) p.log.success('Ambient mode hook installed'); + p.log.info(`Working memory ${memoryEnabled ? 'enabled' : 'disabled'}`); + p.log.info(`HUD ${hudEnabled ? 'enabled' : 'disabled'}`); } - } catch { /* settings.json may not exist yet */ } - - // Ensure .memory/ exists when memory is enabled (hooks are no-ops without it) - if (memoryEnabled) { - await createMemoryDir(verbose); - await migrateMemoryFiles(verbose); } + } catch { /* settings.json may not exist yet */ } - // Configure HUD - const existingHud = loadHudConfig(); - saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail }); - - // Update statusLine in settings.json (add or remove based on choice) - try { - const hudContent = await fs.readFile(settingsPath, 'utf-8'); - const hudUpdated = hudEnabled - ? addHudStatusLine(hudContent, devflowDir) - : removeHudStatusLine(hudContent); - if (hudUpdated !== hudContent) { - await fs.writeFile(settingsPath, hudUpdated, 'utf-8'); - if (verbose) { - p.log.info(`HUD ${hudEnabled ? 'enabled' : 'disabled'}`); - } - } - } catch { /* settings.json may not exist yet */ } + // Ensure .memory/ exists when memory is enabled (hooks are no-ops without it) + if (memoryEnabled) { + await createMemoryDir(verbose); + await migrateMemoryFiles(verbose); } - const fileExtras = selectedExtras.filter(e => e !== 'settings' && e !== 'safe-delete'); - if (fileExtras.length > 0) { - const sExtras = p.spinner(); - sExtras.start('Configuring extras'); + // Configure HUD + const existingHud = loadHudConfig(); + saveHudConfig({ enabled: hudEnabled, detail: existingHud.detail }); - if (selectedExtras.includes('claudeignore') && gitRoot) { + // File extras + if (claudeignoreEnabled) { + if (scope === 'user' && discoveredProjects.length > 0) { + const results = await Promise.all( + discoveredProjects.map(root => installClaudeignore(root, rootDir, verbose)), + ); + const created = results.filter(Boolean).length; + if (created > 0) { + p.log.success(`.claudeignore created in ${created} project(s)`); + } else { + p.log.info(`.claudeignore already exists in all ${discoveredProjects.length} project(s)`); + } + } else if (gitRoot) { await installClaudeignore(gitRoot, rootDir, verbose); } - if (selectedExtras.includes('gitignore') && gitRoot) { - await updateGitignore(gitRoot, verbose); - } - if (selectedExtras.includes('docs')) { - await createDocsStructure(verbose); - } + } + if (scope === 'local' && gitRoot) { + await updateGitignore(gitRoot, verbose); + } + if (scope === 'local') { + await createDocsStructure(verbose); + } + + // Safe-delete execution (decision was captured during prompt phase) + if (safeDeleteAction === 'install' && safeDeleteBlock && profilePath) { + await installToProfile(profilePath, safeDeleteBlock); + } else if (safeDeleteAction === 'upgrade' && safeDeleteBlock && profilePath) { + await removeFromProfile(profilePath); + await installToProfile(profilePath, safeDeleteBlock); + } - sExtras.stop('Extras configured'); + // Managed settings (last install step — sudo may prompt for password) + if (securityMode === 'managed' && managedSettingsConfirmed) { + s.stop('Installing managed settings (may prompt for password)'); + const managed = await installManagedSettings(rootDir, verbose); + if (!managed) { + p.log.warn('Managed settings write failed — falling back to user settings'); + } + s.start('Finalizing'); } - // Summary output + s.stop('Installation complete'); + + // === Summary === + if (usedNativeCli) { p.log.success('Installed via Claude plugin system'); } else if (!cliAvailable) { @@ -662,51 +772,28 @@ export const initCommand = new Command('init') p.note(commandsNote, 'Available commands'); } - // Safe-delete auto-install (gated by extras selection) - if (selectedExtras.includes('safe-delete')) { - const platform = detectPlatform(); - const shell = detectShell(); - const safeDeleteInfo = getSafeDeleteInfo(platform); - const safeDeleteAvailable = hasSafeDelete(platform); - const profilePath = getProfilePath(shell); - - if (process.stdin.isTTY && profilePath) { - if (!safeDeleteAvailable && safeDeleteInfo.installHint) { - p.log.info(`Install ${color.cyan(safeDeleteInfo.command ?? 'trash')} first: ${color.dim(safeDeleteInfo.installHint)}`); - p.log.info(`Then re-run ${color.cyan('devflow init')} to auto-configure safe-delete.`); - } else if (safeDeleteAvailable) { - const trashCmd = safeDeleteInfo.command; - const block = generateSafeDeleteBlock(shell, process.platform, trashCmd); - - if (block) { - const installedVersion = await getInstalledVersion(profilePath); - if (installedVersion === SAFE_DELETE_BLOCK_VERSION) { - p.log.info(`Safe-delete already configured in ${color.dim(profilePath)}`); - } else if (installedVersion > 0) { - await removeFromProfile(profilePath); - await installToProfile(profilePath, block); - p.log.success(`Safe-delete upgraded in ${color.dim(profilePath)}`); - p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`)); - } else { - const confirm = await p.confirm({ - message: `Install safe-delete to ${profilePath}? (overrides rm to use ${trashCmd ?? 'recycle bin'})`, - initialValue: true, - }); - - if (!p.isCancel(confirm) && confirm) { - await installToProfile(profilePath, block); - p.log.success(`Safe-delete installed to ${color.dim(profilePath)}`); - p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`)); - } - } - } - } - } else if (!process.stdin.isTTY) { - if (safeDeleteAvailable && safeDeleteInfo.command) { - p.log.info(`Safe-delete available (${safeDeleteInfo.command}). Run interactively to auto-install.`); - } else if (safeDeleteInfo.installHint) { - p.log.info(`Protect against accidental ${color.red('rm -rf')}: ${color.cyan(safeDeleteInfo.installHint)}`); + // Safe-delete status messages (after spinner) + if (process.stdin.isTTY && profilePath) { + if (safeDeleteAction === 'install') { + p.log.success(`Safe-delete installed to ${color.dim(profilePath)}`); + p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`)); + } else if (safeDeleteAction === 'upgrade') { + p.log.success(`Safe-delete upgraded in ${color.dim(profilePath)}`); + p.log.info('Restart your shell or run: ' + color.cyan(`source ${profilePath}`)); + } else if (safeDeleteAvailable && safeDeleteBlock) { + const installedVersion = await getInstalledVersion(profilePath); + if (installedVersion === SAFE_DELETE_BLOCK_VERSION) { + p.log.info(`Safe-delete already configured in ${color.dim(profilePath)}`); } + } else if (!safeDeleteAvailable && safeDeleteInfo.installHint) { + p.log.info(`Install ${color.cyan(safeDeleteInfo.command ?? 'trash')} first: ${color.dim(safeDeleteInfo.installHint)}`); + p.log.info(`Then re-run ${color.cyan('devflow init')} to auto-configure safe-delete.`); + } + } else if (!process.stdin.isTTY) { + if (safeDeleteAvailable && safeDeleteInfo.command) { + p.log.info(`Safe-delete available (${safeDeleteInfo.command}). Run interactively to auto-install.`); + } else if (safeDeleteInfo.installHint) { + p.log.info(`Protect against accidental ${color.red('rm -rf')}: ${color.cyan(safeDeleteInfo.installHint)}`); } } diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts index a5321e4..8210eb8 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -1,6 +1,7 @@ import { promises as fs, writeFileSync, unlinkSync } from 'fs'; import { execSync } from 'child_process'; import * as path from 'path'; +import * as os from 'os'; import * as p from '@clack/prompts'; import { getManagedSettingsPath } from './paths.js'; @@ -394,12 +395,13 @@ export async function installSettings( /** * Create .claudeignore in git repository root (skip if already exists). + * Returns true if a new file was created, false if it already existed or on error. */ export async function installClaudeignore( gitRoot: string, rootDir: string, verbose: boolean, -): Promise { +): Promise { const claudeignorePath = path.join(gitRoot, '.claudeignore'); const claudeignoreTemplatePath = path.join(rootDir, 'src', 'templates', 'claudeignore.template'); @@ -409,13 +411,56 @@ export async function installClaudeignore( if (verbose) { p.log.success('.claudeignore created'); } + return true; } catch (error: unknown) { if (isNodeSystemError(error) && error.code === 'EEXIST') { // Already exists, skip silently } else if (verbose) { p.log.warn(`Could not create .claudeignore: ${error}`); } + return false; + } +} + +/** + * Discover git repository roots from Claude's project history. + * Parses ~/.claude/history.jsonl for unique project paths that are valid git repos. + * @param homeDir - Override home directory (dependency injection for tests) + */ +export async function discoverProjectGitRoots(homeDir?: string): Promise { + const historyPath = path.join(homeDir ?? os.homedir(), '.claude', 'history.jsonl'); + let content: string; + try { + content = await fs.readFile(historyPath, 'utf-8'); + } catch { + return []; + } + + const projects = new Set(); + for (const line of content.split('\n')) { + if (!line.trim()) continue; + try { + const entry = JSON.parse(line) as Record; + if (typeof entry?.project === 'string') { + projects.add(path.resolve(entry.project)); + } + } catch { + // Malformed line — skip + } } + + const results = await Promise.allSettled( + [...projects].map(async (project) => { + await fs.access(path.join(project, '.git')); + return project; + }), + ); + + const gitRoots: string[] = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); + + return gitRoots.sort(); } /** diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index 90575dd..e5d2c13 100644 --- a/tests/init-logic.test.ts +++ b/tests/init-logic.test.ts @@ -6,19 +6,13 @@ import { parsePluginSelection, substituteSettingsTemplate, computeGitignoreAppend, - buildExtrasOptions, applyTeamsConfig, stripTeamsConfig, mergeDenyList, - addAmbientHook, - removeAmbientHook, - hasAmbientHook, - addMemoryHooks, - removeMemoryHooks, - hasMemoryHooks, + discoverProjectGitRoots, } from '../src/cli/commands/init.js'; import { getManagedSettingsPath } from '../src/cli/utils/paths.js'; -import { installManagedSettings } from '../src/cli/utils/post-install.js'; +import { installManagedSettings, installClaudeignore } from '../src/cli/utils/post-install.js'; import { installViaFileCopy, type Spinner } from '../src/cli/utils/installer.js'; import { DEVFLOW_PLUGINS, buildAssetMaps } from '../src/cli/plugins.js'; @@ -89,40 +83,6 @@ describe('substituteSettingsTemplate', () => { }); }); -describe('buildExtrasOptions', () => { - it('returns settings, safe-delete for user scope without gitRoot', () => { - const options = buildExtrasOptions('user', null); - const values = options.map(o => o.value); - expect(values).toEqual(['settings', 'safe-delete']); - }); - - it('adds claudeignore when gitRoot exists (user scope)', () => { - const options = buildExtrasOptions('user', '/repo'); - const values = options.map(o => o.value); - expect(values).toEqual(['settings', 'claudeignore', 'safe-delete']); - }); - - it('returns all 5 options for local scope with gitRoot', () => { - const options = buildExtrasOptions('local', '/repo'); - const values = options.map(o => o.value); - expect(values).toEqual(['settings', 'claudeignore', 'gitignore', 'docs', 'safe-delete']); - }); - - it('omits claudeignore and gitignore for local scope without gitRoot', () => { - const options = buildExtrasOptions('local', null); - const values = options.map(o => o.value); - expect(values).toEqual(['settings', 'docs', 'safe-delete']); - }); - - it('all options have non-empty label and hint', () => { - const options = buildExtrasOptions('local', '/repo'); - for (const option of options) { - expect(option.label.length).toBeGreaterThan(0); - expect(option.hint.length).toBeGreaterThan(0); - } - }); -}); - describe('computeGitignoreAppend', () => { it('returns all entries when gitignore is empty', () => { const result = computeGitignoreAppend('', ['.claude/', '.devflow/']); @@ -326,34 +286,6 @@ describe('mergeDenyList', () => { }); }); -describe('ambient hook re-exports from init', () => { - it('re-exports addAmbientHook from ambient.ts', () => { - expect(typeof addAmbientHook).toBe('function'); - }); - - it('re-exports removeAmbientHook from ambient.ts', () => { - expect(typeof removeAmbientHook).toBe('function'); - }); - - it('re-exports hasAmbientHook from ambient.ts', () => { - expect(typeof hasAmbientHook).toBe('function'); - }); -}); - -describe('memory hook re-exports from init', () => { - it('re-exports addMemoryHooks from memory.ts', () => { - expect(typeof addMemoryHooks).toBe('function'); - }); - - it('re-exports removeMemoryHooks from memory.ts', () => { - expect(typeof removeMemoryHooks).toBe('function'); - }); - - it('re-exports hasMemoryHooks from memory.ts', () => { - expect(typeof hasMemoryHooks).toBe('function'); - }); -}); - describe('installManagedSettings', () => { let tmpDir: string; let managedDir: string; @@ -531,3 +463,160 @@ describe('installViaFileCopy cleanup (isPartialInstall)', () => { expect(staleAgent).toBe('# stale'); }); }); + +describe('discoverProjectGitRoots', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-discover-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns sorted git roots from history.jsonl', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + await fs.mkdir(claudeDir, { recursive: true }); + + // Create two project dirs with .git + const projA = path.join(tmpDir, 'project-a'); + const projB = path.join(tmpDir, 'project-b'); + await fs.mkdir(path.join(projA, '.git'), { recursive: true }); + await fs.mkdir(path.join(projB, '.git'), { recursive: true }); + + // Write history with both projects (projB before projA to verify sorting) + const lines = [ + JSON.stringify({ project: projB, timestamp: '2026-01-01' }), + JSON.stringify({ project: projA, timestamp: '2026-01-02' }), + ].join('\n'); + await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); + + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([projA, projB]); + }); + + it('skips projects without .git directory', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + await fs.mkdir(claudeDir, { recursive: true }); + + const projGit = path.join(tmpDir, 'has-git'); + const projNoGit = path.join(tmpDir, 'no-git'); + await fs.mkdir(path.join(projGit, '.git'), { recursive: true }); + await fs.mkdir(projNoGit, { recursive: true }); + + const lines = [ + JSON.stringify({ project: projGit }), + JSON.stringify({ project: projNoGit }), + ].join('\n'); + await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); + + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([projGit]); + }); + + it('skips non-existent project paths', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + await fs.mkdir(claudeDir, { recursive: true }); + + const lines = JSON.stringify({ project: path.join(tmpDir, 'gone') }); + await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); + + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([]); + }); + + it('returns empty array when history.jsonl is missing', async () => { + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([]); + }); + + it('returns empty array when history.jsonl is empty', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + await fs.mkdir(claudeDir, { recursive: true }); + await fs.writeFile(path.join(claudeDir, 'history.jsonl'), '', 'utf-8'); + + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([]); + }); + + it('skips malformed JSON lines gracefully', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + await fs.mkdir(claudeDir, { recursive: true }); + + const proj = path.join(tmpDir, 'valid'); + await fs.mkdir(path.join(proj, '.git'), { recursive: true }); + + const lines = [ + 'not valid json', + JSON.stringify({ project: proj }), + '{broken', + ].join('\n'); + await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); + + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([proj]); + }); + + it('deduplicates repeated project entries', async () => { + const claudeDir = path.join(tmpDir, '.claude'); + await fs.mkdir(claudeDir, { recursive: true }); + + const proj = path.join(tmpDir, 'dupe-proj'); + await fs.mkdir(path.join(proj, '.git'), { recursive: true }); + + const lines = [ + JSON.stringify({ project: proj }), + JSON.stringify({ project: proj }), + JSON.stringify({ project: proj }), + ].join('\n'); + await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); + + const roots = await discoverProjectGitRoots(tmpDir); + expect(roots).toEqual([proj]); + }); +}); + +describe('installClaudeignore return value', () => { + let tmpDir: string; + let rootDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-claudeignore-test-')); + rootDir = path.join(tmpDir, 'root'); + await fs.mkdir(path.join(rootDir, 'src', 'templates'), { recursive: true }); + await fs.writeFile( + path.join(rootDir, 'src', 'templates', 'claudeignore.template'), + '# .claudeignore\nnode_modules/\n', + 'utf-8', + ); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('returns true when .claudeignore is newly created', async () => { + const gitRoot = path.join(tmpDir, 'project'); + await fs.mkdir(gitRoot, { recursive: true }); + + const result = await installClaudeignore(gitRoot, rootDir, false); + expect(result).toBe(true); + + const content = await fs.readFile(path.join(gitRoot, '.claudeignore'), 'utf-8'); + expect(content).toContain('node_modules/'); + }); + + it('returns false when .claudeignore already exists', async () => { + const gitRoot = path.join(tmpDir, 'project'); + await fs.mkdir(gitRoot, { recursive: true }); + await fs.writeFile(path.join(gitRoot, '.claudeignore'), '# existing', 'utf-8'); + + const result = await installClaudeignore(gitRoot, rootDir, false); + expect(result).toBe(false); + + // Should not overwrite existing file + const content = await fs.readFile(path.join(gitRoot, '.claudeignore'), 'utf-8'); + expect(content).toBe('# existing'); + }); +});