Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/__tests__/domains/migration/metadata-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
12 changes: 6 additions & 6 deletions src/commands/init/phases/conflict-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -19,14 +19,14 @@ export async function handleConflicts(ctx: InitContext): Promise<InitContext> {
// 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;
}

Expand Down
29 changes: 29 additions & 0 deletions src/commands/init/phases/selection-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,35 @@ export async function handleSelection(ctx: InitContext): Promise<InitContext> {
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) {
Expand Down
23 changes: 23 additions & 0 deletions src/commands/new/phases/directory-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion src/commands/uninstall/installation-detector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -15,15 +17,20 @@ export interface Installation {

/**
* Detect both local and global ClaudeKit installations
* Deduplicates when at HOME directory (local path === global path)
*/
export async function detectInstallations(): Promise<Installation[]> {
const installations: Installation[] = [];

// 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,
Expand Down
31 changes: 23 additions & 8 deletions src/commands/uninstall/uninstall-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -111,14 +112,28 @@ 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";
} else if (validOptions.local) {
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);
Expand All @@ -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;
Expand All @@ -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, {
Expand All @@ -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);
Expand All @@ -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) {
Expand Down
3 changes: 3 additions & 0 deletions src/domains/migration/legacy-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down
16 changes: 12 additions & 4 deletions src/domains/migration/metadata-migration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"];
}
Expand Down
9 changes: 7 additions & 2 deletions src/domains/skills/customization/hash-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -16,8 +17,12 @@ export async function getAllFiles(dirPath: string): Promise<string[]> {
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;
}

Expand Down
19 changes: 13 additions & 6 deletions src/domains/skills/skills-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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({
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}

Expand Down
Loading