diff --git a/.gitignore b/.gitignore index 23c021f8..099eefeb 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,7 @@ config/local-* commands/local-* # Installation logs -install.log \ No newline at end of file +install.log +# DevFlow local scope installation (use --scope local) +.claude/ +.devflow/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 130acdaa..95530935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,66 @@ All notable changes to DevFlow will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.5.0] - 2025-10-24 + +### Added + +#### Installation Scope Support +- **Two-tier installation strategy** - Choose between user-wide and project-specific installation + - **User scope** (default): Install to `~/.claude/` for all projects + - **Local scope**: Install to `/.claude/` for current project only + - Interactive prompt with clear descriptions when `--scope` flag not provided + - CLI flag: `devflow init --scope ` + - Automatic .gitignore updates for local scope (excludes `.claude/` and `.devflow/`) + - Perfect for team projects where DevFlow should be project-specific + +#### Smart Uninstall with Scope Detection +- **Auto-detection of installed scopes** - Intelligently finds and removes DevFlow installations + - Automatically detects which scopes have DevFlow installed (user and/or local) + - Default behavior: Remove from all detected scopes + - Manual override: `--scope ` to target specific scope + - Clear feedback showing which scopes are being uninstalled + - Graceful handling when no installation found + +### Changed + +#### Code Quality Improvements +- **Extracted shared utilities** - Eliminated code duplication between init and uninstall commands + - Created `src/cli/utils/paths.ts` for path resolution functions + - Created `src/cli/utils/git.ts` for git repository operations + - Reduced duplication by ~65 lines + - Single source of truth for path and git logic + +#### Performance Optimizations +- **Eliminated redundant git detection** - Cache git root result for reuse + - Previously called `git rev-parse` twice during installation + - Now cached once and reused throughout installation process + - Faster installation, especially in large repositories + +### Fixed + +#### CI/CD Compatibility +- **TTY detection for interactive prompts** - Prevents hanging in non-interactive environments + - Detects when running in CI/CD pipelines, Docker containers, or automated scripts + - Falls back to default scope (user) when no TTY available + - Clear messaging when non-interactive environment detected + - Explicit instructions for CI/CD usage: `devflow init --scope ` + +#### Security Hardening +- **Environment variable path validation** - Prevents malicious path overrides + - Validates `CLAUDE_CODE_DIR` and `DEVFLOW_DIR` are absolute paths + - Warns when paths point outside user's home directory + - Prevents path traversal attacks via environment variables + - Security-first approach to custom path configuration + +### Documentation +- **Installation Scopes section** in README with clear use cases +- **Updated CLI commands table** with scope options for init and uninstall +- **Migration guide** for existing users (scope defaults to user for compatibility) +- **.gitignore patterns** documented for local scope installations + +--- + ## [0.4.0] - 2025-10-21 ### Added @@ -357,6 +417,7 @@ devflow init --- +[0.5.0]: https://github.com/dean0x/devflow/releases/tag/v0.5.0 [0.4.0]: https://github.com/dean0x/devflow/releases/tag/v0.4.0 [0.3.3]: https://github.com/dean0x/devflow/releases/tag/v0.3.3 [0.3.2]: https://github.com/dean0x/devflow/releases/tag/v0.3.2 diff --git a/README.md b/README.md index 40ffbe80..bad03abf 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,29 @@ A comprehensive collection of Claude Code commands and configurations designed t npx devflow-kit init ``` +### Installation Scopes + +DevFlow supports two installation scopes: + +**User Scope (Default)** - Install for all projects +```bash +npx devflow-kit init --scope user +# Or interactively: npx devflow-kit init (prompts for scope) +``` +- Installs to `~/.claude/` and `~/.devflow/` +- Available across all projects +- Recommended for personal use + +**Local Scope** - Install for current project only +```bash +npx devflow-kit init --scope local +``` +- Installs to `/.claude/` and `/.devflow/` +- Only available in the current project +- Recommended for team projects where DevFlow should be project-specific +- Requires a git repository (run `git init` first) +- Add `.claude/` and `.devflow/` to `.gitignore` (done automatically) + That's it! DevFlow is now installed and ready to use in Claude Code. ## What's Included @@ -154,10 +177,12 @@ Covers patterns for all major languages and operating systems. | Command | Purpose | Options | |---------|---------|---------| -| `devflow init` | Initialize DevFlow for Claude Code | `--skip-docs` - Skip creating `.docs/` structure | -| `devflow uninstall` | Remove DevFlow from Claude Code | `--keep-docs` - Keep `.docs/` directory | +| `devflow init` | Initialize DevFlow for Claude Code | `--scope ` - Installation scope (user: user-wide, local: project-only)
`--skip-docs` - Skip creating `.docs/` structure | +| `devflow uninstall` | Remove DevFlow from Claude Code | `--scope ` - Uninstall from specific scope only (default: auto-detect all)
`--keep-docs` - Keep `.docs/` directory | **What `devflow init` does:** + +**User Scope** (default): - Installs commands to `~/.claude/commands/devflow/` - Installs sub-agents to `~/.claude/agents/devflow/` - Installs skills to `~/.claude/skills/devflow/` @@ -166,6 +191,16 @@ Covers patterns for all major languages and operating systems. - Creates `.claudeignore` at git repository root - Creates `.docs/` structure for project documentation +**Local Scope** (`--scope local`): +- Installs commands to `/.claude/commands/devflow/` +- Installs sub-agents to `/.claude/agents/devflow/` +- Installs skills to `/.claude/skills/devflow/` +- Installs scripts to `/.devflow/scripts/` +- Creates `/.claude/settings.json` (statusline and model) +- Creates `.claudeignore` at git repository root +- Creates `.docs/` structure for project documentation +- Adds `.claude/` and `.devflow/` to `.gitignore` + **First Run:** ```bash devflow init diff --git a/package.json b/package.json index 0eb91e23..58ac3751 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devflow-kit", - "version": "0.4.0", + "version": "0.5.0", "description": "Agentic Development Toolkit for Claude Code - Enhance AI-assisted development with intelligent commands and workflows", "main": "dist/index.js", "type": "module", diff --git a/src/cli/commands/init.ts b/src/cli/commands/init.ts index 17a4fcfc..55bbab8a 100644 --- a/src/cli/commands/init.ts +++ b/src/cli/commands/init.ts @@ -1,49 +1,15 @@ import { Command } from 'commander'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { homedir } from 'os'; -import { execSync } from 'child_process'; import { fileURLToPath } from 'url'; import { dirname } from 'path'; import * as readline from 'readline'; +import { getInstallationPaths } from '../utils/paths.js'; +import { getGitRoot } from '../utils/git.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); -/** - * Get home directory with proper fallback and validation - * Priority: process.env.HOME > os.homedir() - */ -function getHomeDirectory(): string { - const home = process.env.HOME || homedir(); - if (!home) { - throw new Error('Unable to determine home directory. Set HOME environment variable.'); - } - return home; -} - -/** - * Get Claude Code directory with environment variable override support - * Priority: CLAUDE_CODE_DIR env var > ~/.claude - */ -function getClaudeDirectory(): string { - if (process.env.CLAUDE_CODE_DIR) { - return process.env.CLAUDE_CODE_DIR; - } - return path.join(getHomeDirectory(), '.claude'); -} - -/** - * Get DevFlow directory with environment variable override support - * Priority: DEVFLOW_DIR env var > ~/.devflow - */ -function getDevFlowDirectory(): string { - if (process.env.DEVFLOW_DIR) { - return process.env.DEVFLOW_DIR; - } - return path.join(getHomeDirectory(), '.devflow'); -} - /** * Prompt user for confirmation (async) */ @@ -64,8 +30,7 @@ async function promptUser(question: string): Promise { export const initCommand = new Command('init') .description('Initialize DevFlow for Claude Code') .option('--skip-docs', 'Skip creating .docs/ structure') - .option('--force', 'Override existing settings.json and CLAUDE.md (prompts for confirmation)') - .option('-y, --yes', 'Auto-approve all prompts (use with --force)') + .option('--scope ', 'Installation scope: user (user-wide) or local (project-only)', /^(user|local)$/i) .action(async (options) => { // Get package version const packageJsonPath = path.resolve(__dirname, '../../package.json'); @@ -77,45 +42,92 @@ export const initCommand = new Command('init') version = 'unknown'; } - console.log(`๐Ÿš€ DevFlow v${version}${options.force ? ' [--force]' : ''}\n`); + console.log(`๐Ÿš€ DevFlow v${version}\n`); + + // Determine installation scope + let scope: 'user' | 'local' = 'user'; // Default to user for backwards compatibility + + if (options.scope) { + scope = options.scope.toLowerCase() as 'user' | 'local'; + } else { + // Check if running in interactive terminal (TTY) + if (!process.stdin.isTTY) { + // Non-interactive environment (CI/CD, scripts) - use default + console.log('๐Ÿ“ฆ Non-interactive environment detected, using default scope: user'); + console.log(' To specify scope in CI/CD, use: devflow init --scope \n'); + scope = 'user'; + } else { + // Interactive prompt for scope + console.log('๐Ÿ“ฆ Installation Scope:\n'); + console.log(' user - Install for all projects (user-wide)'); + console.log(' โ””โ”€ ~/.claude/ and ~/.devflow/'); + console.log(' local - Install for current project only'); + console.log(' โ””โ”€ /.claude/ and /.devflow/\n'); + + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout + }); + + const answer = await new Promise((resolve) => { + rl.question('Choose scope (user/local) [user]: ', (input) => { + rl.close(); + resolve(input.trim().toLowerCase() || 'user'); + }); + }); + + if (answer === 'local' || answer === 'l') { + scope = 'local'; + } else if (answer === 'user' || answer === 'u' || answer === '') { + scope = 'user'; + } else { + console.error('โŒ Invalid scope. Use "user" or "local"\n'); + process.exit(1); + } + console.log(); + } + } // Get installation paths with proper validation let claudeDir: string; let devflowDir: string; + let gitRoot: string | null = null; try { - claudeDir = getClaudeDirectory(); - devflowDir = getDevFlowDirectory(); + const paths = await getInstallationPaths(scope); + claudeDir = paths.claudeDir; + devflowDir = paths.devflowDir; + + // Cache git root for later use (already computed in getInstallationPaths for local scope) + gitRoot = await getGitRoot(); + + console.log(`๐Ÿ“ Installation scope: ${scope}`); + console.log(` Claude dir: ${claudeDir}`); + console.log(` DevFlow dir: ${devflowDir}\n`); } catch (error) { console.error('โŒ Path configuration error:', error instanceof Error ? error.message : error); process.exit(1); } - // Check for Claude Code - try { - await fs.access(claudeDir); - } catch { - console.error(`โŒ Claude Code not detected at ${claudeDir}`); - console.error(' Install from: https://claude.com/claude-code'); - console.error(' Or set CLAUDE_CODE_DIR if installed elsewhere\n'); - process.exit(1); - } - - // Handle --force flag prompt - let forceOverride = false; - if (options.force) { - if (options.yes) { - forceOverride = true; - } else { - console.log('โš ๏ธ WARNING: Force override will replace settings.json and CLAUDE.md'); - console.log(' Backups: settings.json.backup, CLAUDE.md.backup\n'); - forceOverride = await promptUser('Proceed? (y/N): '); - console.log(); - - if (!forceOverride) { - console.log('โŒ Cancelled. Use init without --force for safe installation.\n'); - process.exit(0); - } + // Check for Claude Code (only for user scope) + if (scope === 'user') { + try { + await fs.access(claudeDir); + } catch { + console.error(`โŒ Claude Code not detected at ${claudeDir}`); + console.error(' Install from: https://claude.com/claude-code'); + console.error(' Or set CLAUDE_CODE_DIR if installed elsewhere\n'); + process.exit(1); + } + console.log('โœ“ Claude Code detected'); + } else { + // Local scope - create .claude directory if it doesn't exist + try { + await fs.mkdir(claudeDir, { recursive: true }); + console.log('โœ“ Local .claude directory ready'); + } catch (error) { + console.error(`โŒ Failed to create ${claudeDir}:`, error); + process.exit(1); } } @@ -170,12 +182,10 @@ export const initCommand = new Command('init') await fs.chmod(path.join(scriptsDir, script), 0o755); } - console.log('โœ“ Claude Code detected'); console.log('โœ“ Installing components... (commands, agents, skills, scripts)'); - // Install settings with smart backup + // Install settings.json - never override existing files (atomic operation) const settingsPath = path.join(claudeDir, 'settings.json'); - const managedSettingsPath = path.join(claudeDir, 'managed-settings.json'); const devflowSettingsPath = path.join(claudeDir, 'settings.devflow.json'); const sourceSettingsPath = path.join(claudeSourceDir, 'settings.json'); @@ -186,126 +196,56 @@ export const initCommand = new Command('init') path.join(devflowDir, 'scripts', 'statusline.sh') ); - let settingsAction = ''; - - if (forceOverride) { - // Force override - backup existing and install - try { - await fs.access(settingsPath); - await fs.rename(settingsPath, path.join(claudeDir, 'settings.json.backup')); - } catch { - // No existing file - } - await fs.writeFile(settingsPath, settingsContent, 'utf-8'); - settingsAction = 'force-installed'; - } else { - // Safe installation logic - try { - // Check if user has existing settings.json - await fs.access(settingsPath); - - // User has settings.json - need to preserve it - try { - // Check if managed-settings.json already exists - await fs.access(managedSettingsPath); - - // managed-settings.json exists - install as settings.devflow.json - await fs.writeFile(devflowSettingsPath, settingsContent, 'utf-8'); - settingsAction = 'saved-as-devflow'; - } catch { - // managed-settings.json doesn't exist - safe to backup and install - await fs.rename(settingsPath, managedSettingsPath); - await fs.writeFile(settingsPath, settingsContent, 'utf-8'); - settingsAction = 'backed-up'; - } - } catch { - // No existing settings.json - install normally - await fs.writeFile(settingsPath, settingsContent, 'utf-8'); - settingsAction = 'fresh-install'; + let settingsExists = false; + try { + // Atomic exclusive create - fails if file already exists + await fs.writeFile(settingsPath, settingsContent, { encoding: 'utf-8', flag: 'wx' }); + console.log('โœ“ Settings configured'); + } catch (error: any) { + if (error.code === 'EEXIST') { + // Existing settings.json found - install as settings.devflow.json + settingsExists = true; + await fs.writeFile(devflowSettingsPath, settingsContent, 'utf-8'); + console.log('โš ๏ธ Existing settings.json preserved โ†’ DevFlow config: settings.devflow.json'); + } else { + throw error; } } - // Install CLAUDE.md with smart backup + // Install CLAUDE.md - never override existing files (atomic operation) const claudeMdPath = path.join(claudeDir, 'CLAUDE.md'); const devflowClaudeMdPath = path.join(claudeDir, 'CLAUDE.devflow.md'); const sourceClaudeMdPath = path.join(claudeSourceDir, 'CLAUDE.md'); - let claudeMdAction = ''; - - if (forceOverride) { - // Force override - backup existing and install - try { - await fs.access(claudeMdPath); - await fs.rename(claudeMdPath, path.join(claudeDir, 'CLAUDE.md.backup')); - } catch { - // No existing file - } - await fs.copyFile(sourceClaudeMdPath, claudeMdPath); - claudeMdAction = 'force-installed'; - } else { - // Safe installation logic - try { - // Check if user has existing CLAUDE.md - await fs.access(claudeMdPath); - - // User has CLAUDE.md - install as CLAUDE.devflow.md + let claudeMdExists = false; + try { + // Atomic exclusive create - fails if file already exists + const content = await fs.readFile(sourceClaudeMdPath, 'utf-8'); + await fs.writeFile(claudeMdPath, content, { encoding: 'utf-8', flag: 'wx' }); + console.log('โœ“ CLAUDE.md configured'); + } catch (error: any) { + if (error.code === 'EEXIST') { + // Existing CLAUDE.md found - install as CLAUDE.devflow.md + claudeMdExists = true; await fs.copyFile(sourceClaudeMdPath, devflowClaudeMdPath); - claudeMdAction = 'saved-as-devflow'; - } catch { - // No existing CLAUDE.md - install normally - await fs.copyFile(sourceClaudeMdPath, claudeMdPath); - claudeMdAction = 'fresh-install'; + console.log('โš ๏ธ Existing CLAUDE.md preserved โ†’ DevFlow guide: CLAUDE.devflow.md'); + } else { + throw error; } } - // Show concise status messages - if (settingsAction === 'force-installed') { - console.log('โœ“ Settings force-installed (backup: settings.json.backup)'); - } else if (settingsAction === 'backed-up') { - console.log('โœ“ Settings configured'); - } else if (settingsAction === 'saved-as-devflow') { - console.log('โš ๏ธ Existing settings preserved โ†’ DevFlow saved to settings.devflow.json'); - } else { - console.log('โœ“ Settings configured'); - } - - if (claudeMdAction === 'force-installed') { - console.log('โœ“ CLAUDE.md force-installed (backup: CLAUDE.md.backup)'); - } else if (claudeMdAction === 'saved-as-devflow') { - console.log('โš ๏ธ Existing CLAUDE.md preserved โ†’ DevFlow saved to CLAUDE.devflow.md'); - } else { - console.log('โœ“ CLAUDE.md configured'); - } - // Create .claudeignore in git repository root let claudeignoreCreated = false; try { - // Find git repository root with validation - const gitRootRaw = execSync('git rev-parse --show-toplevel', { - cwd: process.cwd(), - encoding: 'utf-8', - stdio: ['pipe', 'pipe', 'pipe'] // Isolate stderr - }).trim(); - - // Validate git root path (security: prevent injection) - if (!gitRootRaw || gitRootRaw.includes('\n') || gitRootRaw.includes(';') || gitRootRaw.includes('&&')) { - throw new Error('Invalid git root path returned'); - } - - // Validate it's an absolute path - const gitRoot = path.resolve(gitRootRaw); - if (!path.isAbsolute(gitRoot)) { - throw new Error('Git root must be an absolute path'); + // Use cached git root (already computed and validated earlier) + if (!gitRoot) { + throw new Error('Not in a git repository'); } const claudeignorePath = path.join(gitRoot, '.claudeignore'); - // Check if .claudeignore already exists - try { - await fs.access(claudeignorePath); - } catch { - // Create comprehensive .claudeignore - const claudeignoreContent = `# DevFlow .claudeignore - Protects against sensitive files and context pollution + // Atomic exclusive create - only create if doesn't exist + const claudeignoreContent = `# DevFlow .claudeignore - Protects against sensitive files and context pollution # Generated by DevFlow - Edit as needed for your project # === SECURITY: Sensitive Files === @@ -495,9 +435,9 @@ poetry.lock Pipfile.lock `; - await fs.writeFile(claudeignorePath, claudeignoreContent, 'utf-8'); - claudeignoreCreated = true; - } + // Atomic exclusive create - fails if file already exists + await fs.writeFile(claudeignorePath, claudeignoreContent, { encoding: 'utf-8', flag: 'wx' }); + claudeignoreCreated = true; } catch (error) { // Not a git repository or other error - skip .claudeignore creation } @@ -506,6 +446,40 @@ Pipfile.lock console.log('โœ“ .claudeignore created'); } + // For local scope, update .gitignore to exclude .claude/ and .devflow/ + if (scope === 'local' && gitRoot) { + try { + const gitignorePath = path.join(gitRoot, '.gitignore'); + const entriesToAdd = ['.claude/', '.devflow/']; + + let gitignoreContent = ''; + try { + gitignoreContent = await fs.readFile(gitignorePath, 'utf-8'); + } catch { + // .gitignore doesn't exist, will create it + } + + const linesToAdd: string[] = []; + for (const entry of entriesToAdd) { + // Check if entry already exists (exact match or pattern) + if (!gitignoreContent.split('\n').some(line => line.trim() === entry)) { + linesToAdd.push(entry); + } + } + + if (linesToAdd.length > 0) { + const newContent = gitignoreContent + ? `${gitignoreContent.trimEnd()}\n\n# DevFlow local scope installation\n${linesToAdd.join('\n')}\n` + : `# DevFlow local scope installation\n${linesToAdd.join('\n')}\n`; + + await fs.writeFile(gitignorePath, newContent, 'utf-8'); + console.log('โœ“ .gitignore updated (excluded .claude/ and .devflow/)'); + } + } catch (error) { + console.warn('โš ๏ธ Could not update .gitignore:', error instanceof Error ? error.message : error); + } + } + // Offer to install project documentation structure let docsCreated = false; if (!options.skipDocs) { @@ -529,15 +503,16 @@ Pipfile.lock console.log('\nโœ… Installation complete!\n'); // Show manual merge instructions if needed - if (settingsAction === 'saved-as-devflow' || claudeMdAction === 'saved-as-devflow') { - console.log('โš ๏ธ Manual merge required:'); - if (settingsAction === 'saved-as-devflow') { - console.log(' Settings: Merge settings.devflow.json โ†’ settings.json'); + if (settingsExists || claudeMdExists) { + console.log('๐Ÿ“ Manual merge recommended:\n'); + if (settingsExists) { + console.log(' Settings: Review settings.devflow.json and merge desired config into settings.json'); + console.log(' Key setting: statusLine configuration for DevFlow statusline\n'); } - if (claudeMdAction === 'saved-as-devflow') { - console.log(' Instructions: cp ~/.claude/CLAUDE.devflow.md ~/.claude/CLAUDE.md'); + if (claudeMdExists) { + console.log(' Instructions: Review CLAUDE.devflow.md and adopt desired practices'); + console.log(' This contains DevFlow\'s recommended development patterns\n'); } - console.log(); } console.log('Available commands:'); diff --git a/src/cli/commands/uninstall.ts b/src/cli/commands/uninstall.ts index 65621252..65ad74f0 100644 --- a/src/cli/commands/uninstall.ts +++ b/src/cli/commands/uninstall.ts @@ -1,78 +1,106 @@ import { Command } from 'commander'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { homedir } from 'os'; +import { getInstallationPaths, getClaudeDirectory } from '../utils/paths.js'; +import { getGitRoot } from '../utils/git.js'; /** - * Get home directory with proper fallback and validation - * Priority: process.env.HOME > os.homedir() + * Check if DevFlow is installed at the given paths */ -function getHomeDirectory(): string { - const home = process.env.HOME || homedir(); - if (!home) { - throw new Error('Unable to determine home directory. Set HOME environment variable.'); +async function isDevFlowInstalled(claudeDir: string): Promise { + try { + await fs.access(path.join(claudeDir, 'commands', 'devflow')); + return true; + } catch { + return false; } - return home; -} - -/** - * Get Claude Code directory with environment variable override support - * Priority: CLAUDE_CODE_DIR env var > ~/.claude - */ -function getClaudeDirectory(): string { - if (process.env.CLAUDE_CODE_DIR) { - return process.env.CLAUDE_CODE_DIR; - } - return path.join(getHomeDirectory(), '.claude'); -} - -/** - * Get DevFlow directory with environment variable override support - * Priority: DEVFLOW_DIR env var > ~/.devflow - */ -function getDevFlowDirectory(): string { - if (process.env.DEVFLOW_DIR) { - return process.env.DEVFLOW_DIR; - } - return path.join(getHomeDirectory(), '.devflow'); } export const uninstallCommand = new Command('uninstall') .description('Uninstall DevFlow from Claude Code') .option('--keep-docs', 'Keep .docs/ directory and documentation') + .option('--scope ', 'Uninstall from specific scope only (default: auto-detect all)', /^(user|local)$/i) .action(async (options) => { console.log('๐Ÿงน Uninstalling DevFlow...\n'); - let claudeDir: string; - let devflowScriptsDir: string; + // Determine which scopes to uninstall + let scopesToUninstall: ('user' | 'local')[] = []; - try { - claudeDir = getClaudeDirectory(); - devflowScriptsDir = getDevFlowDirectory(); - } catch (error) { - console.error('โŒ Path configuration error:', error instanceof Error ? error.message : error); - process.exit(1); + if (options.scope) { + scopesToUninstall = [options.scope.toLowerCase() as 'user' | 'local']; + } else { + // Auto-detect installed scopes + const userClaudeDir = getClaudeDirectory(); + const gitRoot = await getGitRoot(); + + if (await isDevFlowInstalled(userClaudeDir)) { + scopesToUninstall.push('user'); + } + + if (gitRoot) { + const localClaudeDir = path.join(gitRoot, '.claude'); + if (await isDevFlowInstalled(localClaudeDir)) { + scopesToUninstall.push('local'); + } + } + + if (scopesToUninstall.length === 0) { + console.log('โŒ No DevFlow installation found'); + console.log(' Checked user scope (~/.claude/) and local scope (git-root/.claude/)\n'); + process.exit(1); + } + + if (scopesToUninstall.length > 1) { + console.log('๐Ÿ“ฆ Found DevFlow in multiple scopes:'); + console.log(' - User scope (~/.claude/)'); + console.log(' - Local scope (git-root/.claude/)'); + console.log('\n Uninstalling from both...\n'); + } } let hasErrors = false; - // DevFlow namespace directories to remove - const devflowDirectories = [ - { path: path.join(claudeDir, 'commands', 'devflow'), name: 'commands' }, - { path: path.join(claudeDir, 'agents', 'devflow'), name: 'agents' }, - { path: path.join(claudeDir, 'skills', 'devflow'), name: 'skills' }, - { path: devflowScriptsDir, name: 'scripts' } - ]; + // Uninstall from each scope + for (const scope of scopesToUninstall) { + // Get installation paths for this scope + let claudeDir: string; + let devflowScriptsDir: string; - // Remove all DevFlow directories - for (const dir of devflowDirectories) { try { - await fs.rm(dir.path, { recursive: true, force: true }); - console.log(` โœ… Removed DevFlow ${dir.name}`); + const paths = await getInstallationPaths(scope); + claudeDir = paths.claudeDir; + devflowScriptsDir = paths.devflowDir; + + if (scope === 'user') { + console.log('๐Ÿ“ Uninstalling user scope (~/.claude/)'); + } else { + console.log('๐Ÿ“ Uninstalling local scope (git-root/.claude/)'); + } } catch (error) { - console.error(` โš ๏ธ Could not remove ${dir.name}:`, error); - hasErrors = true; + console.log(`โš ๏ธ Cannot uninstall ${scope} scope: ${error instanceof Error ? error.message : error}\n`); + continue; + } + + // DevFlow namespace directories to remove + const devflowDirectories = [ + { path: path.join(claudeDir, 'commands', 'devflow'), name: 'commands' }, + { path: path.join(claudeDir, 'agents', 'devflow'), name: 'agents' }, + { path: path.join(claudeDir, 'skills', 'devflow'), name: 'skills' }, + { path: devflowScriptsDir, name: 'scripts' } + ]; + + // Remove all DevFlow directories + for (const dir of devflowDirectories) { + try { + await fs.rm(dir.path, { recursive: true, force: true }); + console.log(` โœ… Removed DevFlow ${dir.name}`); + } catch (error) { + console.error(` โš ๏ธ Could not remove ${dir.name}:`, error); + hasErrors = true; + } } + + console.log(); } // Handle .docs directory diff --git a/src/cli/utils/git.ts b/src/cli/utils/git.ts new file mode 100644 index 00000000..ced8006f --- /dev/null +++ b/src/cli/utils/git.ts @@ -0,0 +1,40 @@ +import { exec } from 'child_process'; +import { promisify } from 'util'; +import * as path from 'path'; + +const execAsync = promisify(exec); + +/** + * Get git repository root directory (async, non-blocking) + * Returns null if not in a git repository + * + * Security: Validates output to prevent command injection + * - Rejects paths with injection characters (newlines, semicolons, shell operators) + * - Ensures path is absolute + * - Resolves path canonically + */ +export async function getGitRoot(): Promise { + try { + const { stdout } = await execAsync('git rev-parse --show-toplevel', { + cwd: process.cwd(), + encoding: 'utf-8' + }); + + const gitRootRaw = stdout.trim(); + + // Validate git root path (security: prevent injection) + if (!gitRootRaw || gitRootRaw.includes('\n') || gitRootRaw.includes(';') || gitRootRaw.includes('&&')) { + return null; + } + + // Validate it's an absolute path + const gitRoot = path.resolve(gitRootRaw); + if (!path.isAbsolute(gitRoot)) { + return null; + } + + return gitRoot; + } catch { + return null; + } +} diff --git a/src/cli/utils/paths.ts b/src/cli/utils/paths.ts new file mode 100644 index 00000000..73b96b98 --- /dev/null +++ b/src/cli/utils/paths.ts @@ -0,0 +1,94 @@ +import { homedir } from 'os'; +import * as path from 'path'; +import { getGitRoot } from './git.js'; + +/** + * Get home directory with proper fallback and validation + * Priority: process.env.HOME > os.homedir() + * + * @throws {Error} If unable to determine home directory + */ +export function getHomeDirectory(): string { + const home = process.env.HOME || homedir(); + if (!home) { + throw new Error('Unable to determine home directory. Set HOME environment variable.'); + } + return home; +} + +/** + * Get Claude Code directory with environment variable override support + * Priority: CLAUDE_CODE_DIR env var > ~/.claude + * + * @throws {Error} If CLAUDE_CODE_DIR is invalid (not absolute, outside home) + */ +export function getClaudeDirectory(): string { + if (process.env.CLAUDE_CODE_DIR) { + const customDir = process.env.CLAUDE_CODE_DIR; + + // Validate path is absolute + if (!path.isAbsolute(customDir)) { + throw new Error('CLAUDE_CODE_DIR must be an absolute path'); + } + + // Warn if outside home directory (security best practice) + const home = getHomeDirectory(); + if (!customDir.startsWith(home)) { + console.warn('โš ๏ธ CLAUDE_CODE_DIR is outside home directory. Ensure this is intentional.'); + } + + return customDir; + } + return path.join(getHomeDirectory(), '.claude'); +} + +/** + * Get DevFlow directory with environment variable override support + * Priority: DEVFLOW_DIR env var > ~/.devflow + * + * @throws {Error} If DEVFLOW_DIR is invalid (not absolute, outside home) + */ +export function getDevFlowDirectory(): string { + if (process.env.DEVFLOW_DIR) { + const customDir = process.env.DEVFLOW_DIR; + + // Validate path is absolute + if (!path.isAbsolute(customDir)) { + throw new Error('DEVFLOW_DIR must be an absolute path'); + } + + // Warn if outside home directory (security best practice) + const home = getHomeDirectory(); + if (!customDir.startsWith(home)) { + console.warn('โš ๏ธ DEVFLOW_DIR is outside home directory. Ensure this is intentional.'); + } + + return customDir; + } + return path.join(getHomeDirectory(), '.devflow'); +} + +/** + * Get installation paths based on scope (async, non-blocking) + * @param scope - 'user' or 'local' + * @returns Object with claudeDir and devflowDir + * @throws {Error} If local scope selected but not in a git repository + */ +export async function getInstallationPaths(scope: 'user' | 'local'): Promise<{ claudeDir: string; devflowDir: string }> { + if (scope === 'user') { + return { + claudeDir: getClaudeDirectory(), + devflowDir: getDevFlowDirectory() + }; + } else { + // Local scope - install to git repository root + const gitRoot = await getGitRoot(); + if (!gitRoot) { + throw new Error('Local scope requires a git repository. Run "git init" first or use --scope user'); + } + return { + claudeDir: path.join(gitRoot, '.claude'), + devflowDir: path.join(gitRoot, '.devflow') + }; + } +}