diff --git a/src/__tests__/domains/migration/metadata-migration.test.ts b/src/__tests__/domains/migration/metadata-migration.test.ts index f2a55582..877d55bc 100644 --- a/src/__tests__/domains/migration/metadata-migration.test.ts +++ b/src/__tests__/domains/migration/metadata-migration.test.ts @@ -405,6 +405,18 @@ describe("metadata-migration", () => { expect(result).toEqual(["marketing"]); }); + it("detects BOTH kits from legacy name containing both", () => { + const metadata: Metadata = { + name: "ClaudeKit Engineer + Marketing Bundle", + version: "v1.0.0", + }; + + const result = getInstalledKits(metadata); + expect(result).toContain("engineer"); + expect(result).toContain("marketing"); + expect(result.length).toBe(2); + }); + it("defaults to engineer for unnamed legacy", () => { const metadata: Metadata = { version: "v1.0.0", diff --git a/src/commands/init/phases/conflict-handler.ts b/src/commands/init/phases/conflict-handler.ts index 6ea75141..612d71eb 100644 --- a/src/commands/init/phases/conflict-handler.ts +++ b/src/commands/init/phases/conflict-handler.ts @@ -3,7 +3,7 @@ * Detects and resolves conflicts when using global mode with existing local installation */ -import { join, resolve } from "node:path"; +import { join } from "node:path"; import { logger } from "@/shared/logger.js"; import { PathResolver } from "@/shared/path-resolver.js"; import { pathExists, remove } from "fs-extra"; @@ -19,14 +19,14 @@ export async function handleConflicts(ctx: InitContext): Promise { // Only check in global mode if (!ctx.options.global) return ctx; - // Skip local detection if cwd is the global kit directory itself - const globalKitDir = PathResolver.getGlobalKitDir(); - const cwdResolved = resolve(process.cwd()); - const isInGlobalDir = cwdResolved === globalKitDir || cwdResolved === resolve(globalKitDir, ".."); + // Skip if at HOME directory (local === global, no conflict possible) + if (PathResolver.isLocalSameAsGlobal()) { + return ctx; + } const localSettingsPath = join(process.cwd(), ".claude", "settings.json"); - if (isInGlobalDir || !(await pathExists(localSettingsPath))) { + if (!(await pathExists(localSettingsPath))) { return ctx; } diff --git a/src/commands/init/phases/selection-handler.ts b/src/commands/init/phases/selection-handler.ts index 56d7a0f8..94d7b603 100644 --- a/src/commands/init/phases/selection-handler.ts +++ b/src/commands/init/phases/selection-handler.ts @@ -202,6 +202,35 @@ export async function handleSelection(ctx: InitContext): Promise { const resolvedDir = resolve(targetDir); logger.info(`Target directory: ${resolvedDir}`); + // HOME directory detection: warn if installing to HOME without --global flag + // Installing to HOME's .claude/ is effectively a global installation + if (!ctx.options.global && PathResolver.isLocalSameAsGlobal(resolvedDir)) { + logger.warning("You're at HOME directory. Installing here modifies your GLOBAL ClaudeKit."); + + if (!ctx.isNonInteractive) { + // Interactive mode: offer choices + const choice = await ctx.prompts.selectScope(); + if (choice === "cancel") { + return { ...ctx, cancelled: true }; + } + if (choice === "global") { + // User confirmed global installation - continue with same resolved directory + logger.info("Proceeding with global installation"); + } + // "different" choice would require re-prompting for directory, but for simplicity + // we just cancel and ask them to run from a different directory + if (choice === "different") { + logger.info("Please run 'ck init' from a project directory instead."); + return { ...ctx, cancelled: true }; + } + } else { + // Non-interactive: fail with clear message + logger.error("Cannot use local installation at HOME directory."); + logger.info("Use -g/--global flag or run from a project directory."); + return { ...ctx, cancelled: true }; + } + } + // Check if directory exists (create if global mode) if (!(await pathExists(resolvedDir))) { if (ctx.options.global) { diff --git a/src/commands/new/phases/directory-setup.ts b/src/commands/new/phases/directory-setup.ts index a7868736..0d984a85 100644 --- a/src/commands/new/phases/directory-setup.ts +++ b/src/commands/new/phases/directory-setup.ts @@ -9,6 +9,7 @@ import { ConfigManager } from "@/domains/config/config-manager.js"; import { detectAccessibleKits } from "@/domains/github/kit-access-checker.js"; import type { PromptsManager } from "@/domains/ui/prompts.js"; import { logger } from "@/shared/logger.js"; +import { PathResolver } from "@/shared/path-resolver.js"; import { AVAILABLE_KITS, type KitType, type NewCommandOptions, isValidKitType } from "@/types"; import { pathExists, readdir } from "fs-extra"; import type { DirectorySetupResult, NewContext } from "../types.js"; @@ -123,6 +124,28 @@ export async function directorySetup( const resolvedDir = resolve(targetDir); logger.info(`Target directory: ${resolvedDir}`); + // HOME directory detection: warn if creating project at HOME + // Creating a project at HOME modifies global ~/.claude/ + if (PathResolver.isLocalSameAsGlobal(resolvedDir)) { + logger.warning("You're creating a project at HOME directory."); + logger.warning("This will install to your GLOBAL ~/.claude/ directory."); + + if (!isNonInteractive) { + const choice = await prompts.selectScope(); + if (choice === "cancel" || choice === "different") { + logger.info("Please run 'ck new' from or specify a different directory."); + return null; + } + // choice === "global": user confirmed, continue + logger.info("Proceeding with global installation"); + } else { + // Non-interactive: fail with clear message + logger.error("Cannot create project at HOME directory in non-interactive mode."); + logger.info("Specify a different directory with --dir flag."); + return null; + } + } + // Check if directory exists and is not empty if (await pathExists(resolvedDir)) { const files = await readdir(resolvedDir); diff --git a/src/commands/uninstall/installation-detector.ts b/src/commands/uninstall/installation-detector.ts index 1e80b526..62abd790 100644 --- a/src/commands/uninstall/installation-detector.ts +++ b/src/commands/uninstall/installation-detector.ts @@ -2,9 +2,11 @@ * Installation Detector * * Detects ClaudeKit installations (local and global). + * Handles HOME directory edge case where local === global. */ import { getClaudeKitSetup } from "@/services/file-operations/claudekit-scanner.js"; +import { PathResolver } from "@/shared/path-resolver.js"; import { pathExists } from "fs-extra"; export interface Installation { @@ -15,6 +17,7 @@ export interface Installation { /** * Detect both local and global ClaudeKit installations + * Deduplicates when at HOME directory (local path === global path) */ export async function detectInstallations(): Promise { const installations: Installation[] = []; @@ -22,8 +25,12 @@ export async function detectInstallations(): Promise { // Detect both local and global installations const setup = await getClaudeKitSetup(process.cwd()); + // Check if local and global point to same path (HOME directory edge case) + const isLocalSameAsGlobal = PathResolver.isLocalSameAsGlobal(); + // Add local installation if found (must have metadata to be valid ClaudeKit installation) - if (setup.project.path && setup.project.metadata) { + // Skip if local === global to avoid duplicates + if (setup.project.path && setup.project.metadata && !isLocalSameAsGlobal) { installations.push({ type: "local", path: setup.project.path, diff --git a/src/commands/uninstall/uninstall-command.ts b/src/commands/uninstall/uninstall-command.ts index b8493792..59b292d1 100644 --- a/src/commands/uninstall/uninstall-command.ts +++ b/src/commands/uninstall/uninstall-command.ts @@ -8,6 +8,7 @@ import { getInstalledKits } from "@/domains/migration/metadata-migration.js"; import { PromptsManager } from "@/domains/ui/prompts.js"; import { ManifestWriter } from "@/services/file-operations/manifest-writer.js"; import { logger } from "@/shared/logger.js"; +import { PathResolver } from "@/shared/path-resolver.js"; import { confirm, isCancel, log, select } from "@/shared/safe-prompts.js"; import { type UninstallCommandOptions, UninstallCommandOptionsSchema } from "@/types"; import pc from "picocolors"; @@ -111,7 +112,17 @@ export async function uninstallCommand(options: UninstallCommandOptions): Promis } } - // 5. Determine scope (from flags or interactive prompt) + // 5. Check if running at HOME directory (local === global) + const isAtHome = PathResolver.isLocalSameAsGlobal(); + + // 6. Handle --local flag at HOME directory (invalid scenario) + if (validOptions.local && !validOptions.global && isAtHome) { + log.warn(pc.yellow("Cannot use --local at HOME directory (local path equals global path).")); + log.info("Use -g/--global or run from a project directory."); + return; + } + + // 7. Determine scope (from flags or interactive prompt) let scope: UninstallScope; if (validOptions.all || (validOptions.local && validOptions.global)) { scope = "all"; @@ -119,6 +130,10 @@ export async function uninstallCommand(options: UninstallCommandOptions): Promis scope = "local"; } else if (validOptions.global) { scope = "global"; + } else if (isAtHome) { + // At HOME directory: skip scope prompt, auto-select global + log.info(pc.cyan("Running at HOME directory - targeting global installation")); + scope = "global"; } else { // Interactive: prompt user to choose scope const promptedScope = await promptScope(allInstallations); @@ -129,7 +144,7 @@ export async function uninstallCommand(options: UninstallCommandOptions): Promis scope = promptedScope; } - // 6. Filter installations by scope + // 8. Filter installations by scope const installations = allInstallations.filter((i) => { if (scope === "all") return true; return i.type === scope; @@ -141,13 +156,13 @@ export async function uninstallCommand(options: UninstallCommandOptions): Promis return; } - // 7. Display found installations + // 9. Display found installations displayInstallations(installations, scope); if (validOptions.kit) { log.info(pc.cyan(`Kit-scoped uninstall: ${validOptions.kit} kit only`)); } - // 8. Dry-run mode - skip confirmation + // 10. Dry-run mode - skip confirmation if (validOptions.dryRun) { log.info(pc.yellow("DRY RUN MODE - No files will be deleted")); await removeInstallations(installations, { @@ -159,14 +174,14 @@ export async function uninstallCommand(options: UninstallCommandOptions): Promis return; } - // 9. Force-overwrite warning + // 11. Force-overwrite warning if (validOptions.forceOverwrite) { log.warn( `${pc.yellow(pc.bold("FORCE MODE ENABLED"))}\n${pc.yellow("User modifications will be permanently deleted!")}`, ); } - // 10. Confirm deletion + // 12. Confirm deletion if (!validOptions.yes) { const kitLabel = validOptions.kit ? ` (${validOptions.kit} kit only)` : ""; const confirmed = await confirmUninstall(scope, kitLabel); @@ -176,14 +191,14 @@ export async function uninstallCommand(options: UninstallCommandOptions): Promis } } - // 11. Remove files using manifest + // 13. Remove files using manifest await removeInstallations(installations, { dryRun: false, forceOverwrite: validOptions.forceOverwrite, kit: validOptions.kit, }); - // 12. Success message + // 14. Success message const kitMsg = validOptions.kit ? ` (${validOptions.kit} kit)` : ""; prompts.outro(`ClaudeKit${kitMsg} uninstalled successfully!`); } catch (error) { diff --git a/src/domains/migration/legacy-migration.ts b/src/domains/migration/legacy-migration.ts index 6cda0986..daf4b803 100644 --- a/src/domains/migration/legacy-migration.ts +++ b/src/domains/migration/legacy-migration.ts @@ -4,6 +4,7 @@ import { ManifestWriter } from "@/services/file-operations/manifest-writer.js"; import { OwnershipChecker } from "@/services/file-operations/ownership-checker.js"; import { mapWithLimit } from "@/shared/concurrent-file-ops.js"; import { logger } from "@/shared/logger.js"; +import { SKIP_DIRS_ALL } from "@/shared/skip-directories.js"; import type { Metadata, TrackedFile } from "@/types"; import { writeFile } from "fs-extra"; import { type ReleaseManifest, ReleaseManifestLoader } from "./release-manifest.js"; @@ -68,6 +69,8 @@ export class LegacyMigration { for (const entry of entries) { // Skip metadata.json itself if (entry === "metadata.json") continue; + // Skip build artifacts, venvs, and Claude Code internal dirs + if (SKIP_DIRS_ALL.includes(entry)) continue; const fullPath = join(dir, entry); let stats; diff --git a/src/domains/migration/metadata-migration.ts b/src/domains/migration/metadata-migration.ts index 036fd454..dc99f584 100644 --- a/src/domains/migration/metadata-migration.ts +++ b/src/domains/migration/metadata-migration.ts @@ -227,22 +227,30 @@ export function getAllTrackedFiles(metadata: Metadata): TrackedFile[] { /** * Get installed kits from metadata + * Returns ALL matching kits (not just first match) for legacy format */ export function getInstalledKits(metadata: Metadata): KitType[] { if (metadata.kits) { return Object.keys(metadata.kits) as KitType[]; } - // Legacy format - detect from name using word boundaries to avoid false matches + // Legacy format - detect ALL kits from name using word boundaries const nameToCheck = metadata.name || ""; + const kits: KitType[] = []; + if (/\bengineer\b/i.test(nameToCheck)) { - return ["engineer"]; + kits.push("engineer"); } if (/\bmarketing\b/i.test(nameToCheck)) { - return ["marketing"]; + kits.push("marketing"); + } + + // If kits found, return them + if (kits.length > 0) { + return kits; } - // Default to engineer for legacy + // Default to engineer for legacy installs with version but no identifiable name if (metadata.version) { return ["engineer"]; } diff --git a/src/domains/skills/customization/hash-calculator.ts b/src/domains/skills/customization/hash-calculator.ts index 4d436e0a..05f8b91f 100644 --- a/src/domains/skills/customization/hash-calculator.ts +++ b/src/domains/skills/customization/hash-calculator.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { readFile, readdir } from "node:fs/promises"; import { join, relative } from "node:path"; +import { BUILD_ARTIFACT_DIRS } from "@/shared/skip-directories.js"; /** * Get all files in a directory recursively @@ -16,8 +17,12 @@ export async function getAllFiles(dirPath: string): Promise { for (const entry of entries) { const fullPath = join(dirPath, entry.name); - // Skip hidden files, node_modules, and symlinks - if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.isSymbolicLink()) { + // Skip hidden files, build artifacts (node_modules, .venv, etc.), and symlinks + if ( + entry.name.startsWith(".") || + BUILD_ARTIFACT_DIRS.includes(entry.name) || + entry.isSymbolicLink() + ) { continue; } diff --git a/src/domains/skills/skills-manifest.ts b/src/domains/skills/skills-manifest.ts index 3c6cf8eb..deeacc8e 100644 --- a/src/domains/skills/skills-manifest.ts +++ b/src/domains/skills/skills-manifest.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import { readFile, readdir, writeFile } from "node:fs/promises"; import { join, relative } from "node:path"; import { logger } from "@/shared/logger.js"; +import { BUILD_ARTIFACT_DIRS } from "@/shared/skip-directories.js"; import type { SkillsManifest } from "@/types"; import { SkillsManifestSchema, SkillsMigrationError } from "@/types"; import { pathExists } from "fs-extra"; @@ -93,10 +94,12 @@ export class SkillsManifestManager { private static async detectStructure(skillsDir: string): Promise<"flat" | "categorized"> { const entries = await readdir(skillsDir, { withFileTypes: true }); - // Filter out manifest and common files + // Filter out manifest and build artifact directories const dirs = entries.filter( (entry) => - entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith("."), + entry.isDirectory() && + !BUILD_ARTIFACT_DIRS.includes(entry.name) && + !entry.name.startsWith("."), ); if (dirs.length === 0) { @@ -145,7 +148,11 @@ export class SkillsManifestManager { // Flat structure: skills are direct subdirectories const entries = await readdir(skillsDir, { withFileTypes: true }); for (const entry of entries) { - if (entry.isDirectory() && entry.name !== "node_modules" && !entry.name.startsWith(".")) { + if ( + entry.isDirectory() && + !BUILD_ARTIFACT_DIRS.includes(entry.name) && + !entry.name.startsWith(".") + ) { const skillPath = join(skillsDir, entry.name); const hash = await SkillsManifestManager.hashDirectory(skillPath); skills.push({ @@ -160,7 +167,7 @@ export class SkillsManifestManager { for (const category of categories) { if ( category.isDirectory() && - category.name !== "node_modules" && + !BUILD_ARTIFACT_DIRS.includes(category.name) && !category.name.startsWith(".") ) { const categoryPath = join(skillsDir, category.name); @@ -223,8 +230,8 @@ export class SkillsManifestManager { for (const entry of entries) { const fullPath = join(dirPath, entry.name); - // Skip hidden files and node_modules - if (entry.name.startsWith(".") || entry.name === "node_modules") { + // Skip hidden files and build artifacts (node_modules, .venv, etc.) + if (entry.name.startsWith(".") || BUILD_ARTIFACT_DIRS.includes(entry.name)) { continue; } diff --git a/src/domains/ui/prompts.ts b/src/domains/ui/prompts.ts index 4750c282..e1e8c264 100644 --- a/src/domains/ui/prompts.ts +++ b/src/domains/ui/prompts.ts @@ -11,7 +11,7 @@ import { isOpenCodeInstalled, } from "@/services/package-installer/package-installer.js"; import { logger } from "@/shared/logger.js"; -import { confirm, intro, isCancel, log, note, outro } from "@/shared/safe-prompts.js"; +import { confirm, intro, isCancel, log, note, outro, select } from "@/shared/safe-prompts.js"; import type { KitConfig, KitType } from "@/types"; // Re-export all prompts from submodules @@ -201,4 +201,34 @@ export class PromptsManager { async promptDirectorySelection(global = false): Promise { return promptDirectorySelection(global); } + + /** + * Prompt for scope selection when running at HOME directory + * Used when local === global to clarify user intent + */ + async selectScope(): Promise<"global" | "different" | "cancel"> { + const options = [ + { + value: "global" as const, + label: "Install globally", + hint: "Continue installing to ~/.claude/", + }, + { + value: "different" as const, + label: "Use a different directory", + hint: "Cancel and run from a project directory", + }, + ]; + + const selected = await select({ + message: "What would you like to do?", + options, + }); + + if (isCancel(selected)) { + return "cancel"; + } + + return selected; + } } diff --git a/src/services/file-operations/claudekit-scanner.ts b/src/services/file-operations/claudekit-scanner.ts index 816928cf..e238658f 100644 --- a/src/services/file-operations/claudekit-scanner.ts +++ b/src/services/file-operations/claudekit-scanner.ts @@ -135,9 +135,11 @@ export async function getClaudeKitSetup( setup.global.components = await scanClaudeKitDirectory(globalDir); } - // Check project setup + // Check project setup (skip if projectDir is HOME - would be same as global) const projectClaudeDir = join(projectDir, ".claude"); - if (await pathExists(projectClaudeDir)) { + const isLocalSameAsGlobal = projectClaudeDir === globalDir; + + if (!isLocalSameAsGlobal && (await pathExists(projectClaudeDir))) { setup.project.path = projectClaudeDir; setup.project.metadata = await readClaudeKitMetadata(join(projectClaudeDir, "metadata.json")); setup.project.components = await scanClaudeKitDirectory(projectClaudeDir); diff --git a/src/shared/path-resolver.ts b/src/shared/path-resolver.ts index 14155cde..af1a2168 100644 --- a/src/shared/path-resolver.ts +++ b/src/shared/path-resolver.ts @@ -341,4 +341,42 @@ export class PathResolver { static isWSL(): boolean { return isWSL(); } + + /** + * Check if current working directory is the user's HOME directory + * When at HOME, local .claude/ === global .claude/, making scope selection meaningless + * + * @param cwd - Optional current working directory (defaults to process.cwd()) + * @returns true if cwd is the home directory + */ + static isAtHomeDirectory(cwd?: string): boolean { + const currentDir = normalize(cwd || process.cwd()); + const homeDir = normalize(homedir()); + return currentDir === homeDir; + } + + /** + * Get local .claude path for a given directory + * Returns the path that would be used for local installation + * + * @param baseDir - Base directory (defaults to process.cwd()) + * @returns Path to local .claude directory + */ + static getLocalClaudeDir(baseDir?: string): string { + const dir = baseDir || process.cwd(); + return join(dir, ".claude"); + } + + /** + * Check if local and global .claude paths are the same + * This happens when cwd is HOME directory + * + * @param cwd - Optional current working directory + * @returns true if local and global paths would be identical + */ + static isLocalSameAsGlobal(cwd?: string): boolean { + const localPath = normalize(PathResolver.getLocalClaudeDir(cwd)); + const globalPath = normalize(PathResolver.getGlobalKitDir()); + return localPath === globalPath; + } } diff --git a/tests/lib/migration/legacy-migration.test.ts b/tests/lib/migration/legacy-migration.test.ts index fe1246a6..4a69f73b 100644 --- a/tests/lib/migration/legacy-migration.test.ts +++ b/tests/lib/migration/legacy-migration.test.ts @@ -87,6 +87,32 @@ describe("LegacyMigration", () => { expect(files.length).toBe(1); expect(files[0]).toContain("test.txt"); }); + + test("skips node_modules, .venv, and other excluded directories", async () => { + // Create directories that should be skipped + await mkdir(join(tempDir, "node_modules", "package"), { recursive: true }); + await mkdir(join(tempDir, ".venv", "lib"), { recursive: true }); + await mkdir(join(tempDir, "debug"), { recursive: true }); + await mkdir(join(tempDir, "projects"), { recursive: true }); + + // Create files inside excluded dirs + await writeFile(join(tempDir, "node_modules", "package", "index.js"), "module"); + await writeFile(join(tempDir, ".venv", "lib", "python.py"), "venv file"); + await writeFile(join(tempDir, "debug", "log.txt"), "debug log"); + await writeFile(join(tempDir, "projects", "data.json"), "project data"); + + // Create a legitimate file that should be included + await writeFile(join(tempDir, "legit-file.txt"), "real content"); + + const files = await LegacyMigration.scanFiles(tempDir); + + expect(files.length).toBe(1); + expect(files[0]).toContain("legit-file.txt"); + expect(files.some((f) => f.includes("node_modules"))).toBe(false); + expect(files.some((f) => f.includes(".venv"))).toBe(false); + expect(files.some((f) => f.includes("debug"))).toBe(false); + expect(files.some((f) => f.includes("projects"))).toBe(false); + }); }); describe("classifyFiles", () => { diff --git a/tests/utils/path-resolver.test.ts b/tests/utils/path-resolver.test.ts index 55b85238..7727c701 100644 --- a/tests/utils/path-resolver.test.ts +++ b/tests/utils/path-resolver.test.ts @@ -383,6 +383,46 @@ describe("PathResolver", () => { }); }); + describe("HOME directory detection", () => { + it("isAtHomeDirectory returns true when at HOME", () => { + const home = homedir(); + expect(PathResolver.isAtHomeDirectory(home)).toBe(true); + }); + + it("isAtHomeDirectory returns false when not at HOME", () => { + expect(PathResolver.isAtHomeDirectory("/tmp")).toBe(false); + expect(PathResolver.isAtHomeDirectory("/some/project")).toBe(false); + }); + + it("getLocalClaudeDir returns .claude path for given directory", () => { + const result = PathResolver.getLocalClaudeDir("/project"); + expect(result).toBe(join("/project", ".claude")); + }); + + it("isLocalSameAsGlobal returns true when at HOME", () => { + const home = homedir(); + expect(PathResolver.isLocalSameAsGlobal(home)).toBe(true); + }); + + it("isLocalSameAsGlobal returns false when not at HOME", () => { + expect(PathResolver.isLocalSameAsGlobal("/tmp")).toBe(false); + expect(PathResolver.isLocalSameAsGlobal("/some/project")).toBe(false); + }); + + it("isLocalSameAsGlobal respects CK_TEST_HOME", () => { + const testHome = "/tmp/test-home-check"; + process.env.CK_TEST_HOME = testHome; + + // When at test home, local === global + expect(PathResolver.isLocalSameAsGlobal(testHome)).toBe(true); + + // When not at test home, local !== global + expect(PathResolver.isLocalSameAsGlobal("/other/path")).toBe(false); + + process.env.CK_TEST_HOME = undefined; + }); + }); + describe("path consistency", () => { it("should maintain separate paths for local and global modes", () => { const localConfig = PathResolver.getConfigDir(false);