From f23d540ad28ebfa7413f1a6e1224bce471e1a67b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 22 Mar 2026 10:22:46 +0200 Subject: [PATCH 1/5] feat(init): enhance flow with project discovery and improved return types Refactor init flow to support batch .claudeignore installation across discovered projects: - Add discoverProjectGitRoots() to parse ~/.claude/history.jsonl for valid git repositories - Change installClaudeignore() to return boolean (true if newly created) to support batch progress tracking - Re-export discoverProjectGitRoots from init.ts for test accessibility - Clean up unused imports and dead exports (buildExtrasOptions, has* checks for hooks) - Update tests to reflect API changes and removal of dead code This enables user-scope installations to automatically discover and configure all projects Claude has worked on with a single prompt. Co-Authored-By: Claude --- src/cli/commands/init.ts | 526 ++++++++++++++++++++-------------- src/cli/utils/post-install.ts | 45 ++- tests/init-logic.test.ts | 233 ++++++++++----- 3 files changed, 521 insertions(+), 283 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 70ea7e9..7490f3f 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)) { @@ -354,11 +366,17 @@ export const initCommand = new Command('init') // Security deny list placement (user scope + TTY only) 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 +388,143 @@ export const initCommand = new Command('init') securityMode = securityChoice as SecurityMode; } - // Start spinner immediately after prompts — covers path resolution + git detection + // Managed settings sudo confirmation (prompt phase — action deferred to install phase) + 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'; + } + + // .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'; + } + } + } + } + + // ╭──────────────────────────────────────────────────────────╮ + // │ 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,117 @@ 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, - }); - - if (p.isCancel(extrasSelection)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } - - selectedExtras = extrasSelection as ExtraId[]; - } else { - selectedExtras = extrasOptions.map(o => o.value); - } - - // 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); - } - - 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 { + // === Settings & hooks (all automatic based on collected choices) === + s.message('Configuring settings'); + + // Managed settings (sudo may prompt for password — spinner paused) + let effectiveSecurityMode = securityMode; + if (securityMode === 'managed') { + if (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'); effectiveSecurityMode = 'user'; } + s.start('Configuring settings'); + } else { + effectiveSecurityMode = 'user'; } - await installSettings(claudeDir, rootDir, devflowDir, verbose, teamsEnabled, effectiveSecurityMode); + } + await installSettings(claudeDir, rootDir, devflowDir, verbose, teamsEnabled, effectiveSecurityMode); - // Install ambient hook if enabled - 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 */ } - } + const settingsPath = path.join(claudeDir, 'settings.json'); - // Manage memory hooks based on user choice - const settingsPath = path.join(claudeDir, 'settings.json'); + // Install ambient hook if enabled + if (ambientEnabled) { 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; + const updated = addAmbientHook(content, devflowDir); if (updated !== content) { await fs.writeFile(settingsPath, updated, 'utf-8'); if (verbose) { - p.log.info(`Working memory ${memoryEnabled ? 'enabled' : 'disabled'}`); + p.log.success('Ambient mode hook installed'); } } } 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); + // Manage memory hooks based on user choice + 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'}`); + } } + } 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) { - await installClaudeignore(gitRoot, rootDir, verbose); - } - if (selectedExtras.includes('gitignore') && gitRoot) { - await updateGitignore(gitRoot, verbose); + // 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'}`); + } } - if (selectedExtras.includes('docs')) { - await createDocsStructure(verbose); + } catch { /* settings.json may not exist yet */ } + + // File extras + if (claudeignoreEnabled) { + if (scope === 'user' && discoveredProjects.length > 0) { + let created = 0; + for (const root of discoveredProjects) { + if (await installClaudeignore(root, rootDir, verbose)) created++; + } + 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 (scope === 'local' && gitRoot) { + await updateGitignore(gitRoot, verbose); + } + if (scope === 'local') { + await createDocsStructure(verbose); + } - sExtras.stop('Extras configured'); + // 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); } - // Summary output + s.stop('Installation complete'); + + // === Summary === + if (usedNativeCli) { p.log.success('Installed via Claude plugin system'); } else if (!cliAvailable) { @@ -662,51 +787,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..12d71b6 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,54 @@ 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. + */ +export async function discoverProjectGitRoots(): Promise { + const historyPath = path.join(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); + if (typeof entry.project === 'string') { + projects.add(entry.project); + } + } catch { + // Malformed line — skip + } } + + const gitRoots: string[] = []; + for (const project of projects) { + try { + await fs.access(path.join(project, '.git')); + gitRoots.push(project); + } catch { + // Not a git repo or doesn't exist — skip + } + } + + return gitRoots.sort(); } /** diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index 90575dd..c74c04e 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,164 @@ describe('installViaFileCopy cleanup (isPartialInstall)', () => { expect(staleAgent).toBe('# stale'); }); }); + +describe('discoverProjectGitRoots', () => { + let tmpDir: string; + let origHome: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-discover-test-')); + origHome = process.env.HOME!; + process.env.HOME = tmpDir; + }); + + afterEach(async () => { + process.env.HOME = origHome; + 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(); + 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(); + 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(); + expect(roots).toEqual([]); + }); + + it('returns empty array when history.jsonl is missing', async () => { + const roots = await discoverProjectGitRoots(); + 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(); + 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(); + 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(); + 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'); + }); +}); From a558f2a0cf2fb98b69b72d240d84fdb300147078 Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 22 Mar 2026 10:36:03 +0200 Subject: [PATCH 2/5] fix(post-install): harden discoverProjectGitRoots parsing, paths, and perf - Type JSON.parse result as unknown with proper type guard instead of any - Normalize history.jsonl paths with path.resolve() before use - Parallelize fs.access checks with Promise.allSettled Co-Authored-By: Claude --- src/cli/commands/init.ts | 65 ++++++++++++++--------------------- src/cli/utils/post-install.ts | 28 +++++++++------ 2 files changed, 42 insertions(+), 51 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 7490f3f..ed35dd1 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -684,32 +684,31 @@ export const initCommand = new Command('init') const settingsPath = path.join(claudeDir, 'settings.json'); - // Install ambient hook if enabled - if (ambientEnabled) { - 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 */ } - } - - // Manage memory hooks based on user choice + // Configure ambient hook, memory hooks, and HUD statusLine in a single read-modify-write pass try { - const content = await fs.readFile(settingsPath, 'utf-8'); - // Always remove-then-add to upgrade hook format (e.g., .sh → run-hook) + let content = await fs.readFile(settingsPath, 'utf-8'); + const original = content; + + // Ambient hook + if (ambientEnabled) { + content = addAmbientHook(content, devflowDir); + } + + // Memory hooks — 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'); + 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 */ } @@ -724,27 +723,13 @@ export const initCommand = new Command('init') 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 */ } - // File extras if (claudeignoreEnabled) { if (scope === 'user' && discoveredProjects.length > 0) { - let created = 0; - for (const root of discoveredProjects) { - if (await installClaudeignore(root, rootDir, verbose)) created++; - } + 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 { diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts index 12d71b6..4c28a76 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -439,24 +439,30 @@ export async function discoverProjectGitRoots(): Promise { for (const line of content.split('\n')) { if (!line.trim()) continue; try { - const entry = JSON.parse(line); - if (typeof entry.project === 'string') { - projects.add(entry.project); + const entry: unknown = JSON.parse(line); + if ( + typeof entry === 'object' && + entry !== null && + 'project' in entry && + typeof (entry as Record).project === 'string' + ) { + projects.add(path.resolve((entry as Record).project as string)); } } catch { // Malformed line — skip } } - const gitRoots: string[] = []; - for (const project of projects) { - try { + const results = await Promise.allSettled( + [...projects].map(async (project) => { await fs.access(path.join(project, '.git')); - gitRoots.push(project); - } catch { - // Not a git repo or doesn't exist — skip - } - } + return project; + }), + ); + + const gitRoots: string[] = results + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); return gitRoots.sort(); } From bf2d59a394ea0525e2ba862962ffd621ed1d833b Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 22 Mar 2026 10:37:33 +0200 Subject: [PATCH 3/5] docs: add CHANGELOG entries and README --hud flag; fix test isolation - Populate [Unreleased] section with init flow changes (individual feature prompts, project discovery, batch .claudeignore, removed extras multiselect) - Add --hud / --no-hud row to README init flags table - Add optional homeDir parameter to discoverProjectGitRoots for dependency injection, replacing process.env.HOME mutation in tests Co-Authored-By: Claude --- CHANGELOG.md | 14 ++++++++++++++ README.md | 2 +- src/cli/utils/post-install.ts | 5 +++-- tests/init-logic.test.ts | 18 +++++++----------- 4 files changed, 25 insertions(+), 14 deletions(-) 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/utils/post-install.ts b/src/cli/utils/post-install.ts index 4c28a76..9b86d12 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -425,9 +425,10 @@ export async function installClaudeignore( /** * 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(): Promise { - const historyPath = path.join(os.homedir(), '.claude', 'history.jsonl'); +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'); diff --git a/tests/init-logic.test.ts b/tests/init-logic.test.ts index c74c04e..e5d2c13 100644 --- a/tests/init-logic.test.ts +++ b/tests/init-logic.test.ts @@ -466,16 +466,12 @@ describe('installViaFileCopy cleanup (isPartialInstall)', () => { describe('discoverProjectGitRoots', () => { let tmpDir: string; - let origHome: string; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'devflow-discover-test-')); - origHome = process.env.HOME!; - process.env.HOME = tmpDir; }); afterEach(async () => { - process.env.HOME = origHome; await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -496,7 +492,7 @@ describe('discoverProjectGitRoots', () => { ].join('\n'); await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([projA, projB]); }); @@ -515,7 +511,7 @@ describe('discoverProjectGitRoots', () => { ].join('\n'); await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([projGit]); }); @@ -526,12 +522,12 @@ describe('discoverProjectGitRoots', () => { const lines = JSON.stringify({ project: path.join(tmpDir, 'gone') }); await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([]); }); it('returns empty array when history.jsonl is missing', async () => { - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([]); }); @@ -540,7 +536,7 @@ describe('discoverProjectGitRoots', () => { await fs.mkdir(claudeDir, { recursive: true }); await fs.writeFile(path.join(claudeDir, 'history.jsonl'), '', 'utf-8'); - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([]); }); @@ -558,7 +554,7 @@ describe('discoverProjectGitRoots', () => { ].join('\n'); await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([proj]); }); @@ -576,7 +572,7 @@ describe('discoverProjectGitRoots', () => { ].join('\n'); await fs.writeFile(path.join(claudeDir, 'history.jsonl'), lines, 'utf-8'); - const roots = await discoverProjectGitRoots(); + const roots = await discoverProjectGitRoots(tmpDir); expect(roots).toEqual([proj]); }); }); From 33154f132cf9f00a63c362b28945255b6982b25f Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 22 Mar 2026 10:43:58 +0200 Subject: [PATCH 4/5] refactor: simplify discoverProjectGitRoots type narrowing --- src/cli/utils/post-install.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/cli/utils/post-install.ts b/src/cli/utils/post-install.ts index 9b86d12..8210eb8 100644 --- a/src/cli/utils/post-install.ts +++ b/src/cli/utils/post-install.ts @@ -440,14 +440,9 @@ export async function discoverProjectGitRoots(homeDir?: string): Promise).project === 'string' - ) { - projects.add(path.resolve((entry as Record).project as string)); + const entry = JSON.parse(line) as Record; + if (typeof entry?.project === 'string') { + projects.add(path.resolve(entry.project)); } } catch { // Malformed line — skip From 64f5362750dfddb5f12c7f9605b82c2bf356b92c Mon Sep 17 00:00:00 2001 From: Dean Sharon Date: Sun, 22 Mar 2026 10:50:36 +0200 Subject: [PATCH 5/5] refactor(init): move managed settings prompt to end of wizard Security deny list and sudo password prompt now appear after all other choices (safe-delete, .claudeignore, etc.) so the password entry doesn't interrupt the flow mid-wizard. --- src/cli/commands/init.ts | 136 +++++++++++++++++++-------------------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index ed35dd1..7a8161f 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -363,61 +363,6 @@ export const initCommand = new Command('init') hudEnabled = hudChoice; } - // Security deny list placement (user scope + TTY only) - 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 deny list?', - options: [ - { value: 'managed', label: 'Managed settings', hint: 'Recommended — read-only, cannot be overridden' }, - { value: 'user', label: 'User settings', hint: 'Editable in settings.json' }, - ], - }); - - if (p.isCancel(securityChoice)) { - p.cancel('Installation cancelled.'); - process.exit(0); - } - - securityMode = securityChoice as SecurityMode; - } - - // Managed settings sudo confirmation (prompt phase — action deferred to install phase) - 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'; - } - // .claudeignore prompt (needs early git detection) const earlyGitRoot = await getGitRoot(); let claudeignoreEnabled = true; @@ -521,6 +466,61 @@ export const initCommand = new Command('init') } } + // 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 deny list?', + options: [ + { value: 'managed', label: 'Managed settings', hint: 'Recommended — read-only, cannot be overridden' }, + { value: 'user', label: 'User settings', hint: 'Editable in settings.json' }, + ], + }); + + if (p.isCancel(securityChoice)) { + p.cancel('Installation cancelled.'); + process.exit(0); + } + + securityMode = securityChoice as SecurityMode; + } + + // 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 │ // ╰──────────────────────────────────────────────────────────╯ @@ -665,20 +665,10 @@ export const initCommand = new Command('init') // === Settings & hooks (all automatic based on collected choices) === s.message('Configuring settings'); - // Managed settings (sudo may prompt for password — spinner paused) + // Determine effective security mode (managed settings executed later, after safe-delete) let effectiveSecurityMode = securityMode; - if (securityMode === 'managed') { - if (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'); - effectiveSecurityMode = 'user'; - } - s.start('Configuring settings'); - } else { - effectiveSecurityMode = 'user'; - } + if (securityMode === 'managed' && !managedSettingsConfirmed) { + effectiveSecurityMode = 'user'; } await installSettings(claudeDir, rootDir, devflowDir, verbose, teamsEnabled, effectiveSecurityMode); @@ -754,6 +744,16 @@ export const initCommand = new Command('init') await installToProfile(profilePath, safeDeleteBlock); } + // 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'); + } + s.stop('Installation complete'); // === Summary ===