diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe7bfe..c50ae61 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,13 @@ All notable changes to cc-settings are documented here. - Codex bridge hardening: `--force` escape bypasses a sticky rate-limited/no-access verdict (which Codex emits even on auth mismatch); a fresh `available` verdict skips the per-call `codex login status` probe for 60s; timeouts now report partial output + a split/raise hint instead of an opaque exit code; `exec` appends `git status`/`diff --stat` so the changed files are always surfaced; `sanitizeOutput` strips ANSI and redacts secrets (`sk-`/`Bearer`/`Authorization`/`*_API_KEY|TOKEN|SECRET=`) on all returned output. - Hook tamper-defense: the three divergent "managed `~/.claude/src` hook command" classifiers (settings-merge, light-profile, audit-hooks) are unified into `src/lib/hook-command.ts`; the trusted regex is tightened to `(scripts|hooks)` only (drops `lib/`); hook-block traversal goes through a `HooksBlock`-schema-driven `iterCommandHooks`. `readFingerprint`/`readSrcManifest` now validate through zod, strip control chars from `installedAt`, and reject manifest keys with `..`/absolute paths. -- Installer: `buildInstallPlan` is the single source of truth for install/prune footprint; `main()` split into `runMigrateOnly`/`runFullInstall`; the in-installer skill-prune loop removed. `managed-skills.ts` split into `ACTIVE_SKILLS` (completed — 7 missing current skills added: `codex`, `freeze`, `plan-ceo-review`, `proof-of-work`, `retro`, `review-batch`, `strategist`) + `TOMBSTONE_SKILLS`; `lint:skills` now asserts `ACTIVE_SKILLS` matches `skills/` on disk. -- Settings merger is now pure: MCP-server preservation moved out of `settings-merge.ts` into `mcp.ts` `resolveMcpServers` (behavior unchanged). +- Installer: `buildInstallPlan` is the single source of truth for install/prune footprint; `main()` split into `runFullInstall` (the migrate-only path inlined into `main`); the in-installer skill-prune loop removed. `managed-skills.ts` split into `ACTIVE_SKILLS` (completed — 7 missing current skills added: `codex`, `freeze`, `plan-ceo-review`, `proof-of-work`, `retro`, `review-batch`, `strategist`) + `TOMBSTONE_SKILLS`; `lint:skills` now asserts `ACTIVE_SKILLS` matches `skills/` on disk. +- Settings merger is now pure: MCP-server preservation moved out of `settings-merge.ts` into `mcp.ts` `resolveMcpServers` (behavior unchanged). `mergeSettings` no longer prints — it returns `MergeAccounting | null` (null on the fresh-install passthrough) and the caller `installSettings` formats via `printMergeAccounting`, so the merge orchestrator is output-free and testable. +- Nuclear-review structural pass (whole-codebase audit): `src/setup.ts` 1005→707 lines — its display layer extracted to `src/lib/install-display.ts` and the help/rollback commands to `src/lib/install-cmds.ts`; the `runMigrateOnly` stub deleted; the `buildInstallPlan`/`installConfigFiles` plan-vs-reality split removed so the plan honestly drives the light path, and the now-redundant `removeLightIncompatibleFiles` pass collapsed into `installConfigFiles` (one prune path for light, not two). New canonical `src/lib/platform.ts` helpers — `CLAUDE_DIR`/`claudePath()`, `isoNow()`, `localDatetime()` — replace 20+ bespoke `join(homedir(), ".claude")` derivations and five inlined timestamp idioms across scripts/hooks; `post-failure.ts` now uses `readState`/`writeState`; `statusline.ts` routes through `colors.ts` so `NO_COLOR` is honoured. Boundary hardening: `readSrcManifest` validates via a new `SrcManifestRecordSchema`, `auditHooks` consumes the schema-validated hooks block, and `audit-hooks.ts` reads `settings.json` through the canonical `readJsonOrNull`. Installer dry-run output is byte-identical — the pass is behavior-preserving. + +### Dependencies + +- `@biomejs/biome` 2.4.16 → 2.5.0 (+ `biome.json` `$schema` bump). The stricter 2.5.0 ruleset surfaced a dead test helper (`withTmp` in `tests/mcp.test.ts`) and an unused import, both removed; a handful of pre-existing non-blocking style warnings remain for a later sweep. ### Internal diff --git a/biome.json b/biome.json index 17b300b..5680ce9 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.16/schema.json", + "$schema": "https://biomejs.dev/schemas/2.5.0/schema.json", "vcs": { "enabled": true, "clientKind": "git", diff --git a/bun.lock b/bun.lock index b800c19..bdace59 100644 --- a/bun.lock +++ b/bun.lock @@ -10,30 +10,30 @@ "zod": "4.4.3", }, "devDependencies": { - "@biomejs/biome": "2.4.16", + "@biomejs/biome": "2.5.0", "@types/bun": "1.3.14", "typescript": "6.0.3", }, }, }, "packages": { - "@biomejs/biome": ["@biomejs/biome@2.4.16", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.16", "@biomejs/cli-darwin-x64": "2.4.16", "@biomejs/cli-linux-arm64": "2.4.16", "@biomejs/cli-linux-arm64-musl": "2.4.16", "@biomejs/cli-linux-x64": "2.4.16", "@biomejs/cli-linux-x64-musl": "2.4.16", "@biomejs/cli-win32-arm64": "2.4.16", "@biomejs/cli-win32-x64": "2.4.16" }, "bin": { "biome": "bin/biome" } }, "sha512-x9ajFh1zChVybCiM3TN6OD4phAqLgtPZjFrZF+aTMYCPjwBO+k529TX7PPsAqtGNLeV4UgzwQnowEgS7bGmzcA=="], + "@biomejs/biome": ["@biomejs/biome@2.5.0", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.0", "@biomejs/cli-darwin-x64": "2.5.0", "@biomejs/cli-linux-arm64": "2.5.0", "@biomejs/cli-linux-arm64-musl": "2.5.0", "@biomejs/cli-linux-x64": "2.5.0", "@biomejs/cli-linux-x64-musl": "2.5.0", "@biomejs/cli-win32-arm64": "2.5.0", "@biomejs/cli-win32-x64": "2.5.0" }, "bin": { "biome": "bin/biome" } }, "sha512-4kURkd9hAPrdDM3C9n82ycYgx8hvQcW6MjKTEejruj8rK0N8P3OPpdy8BvI8kt3KWY4ycF5XtDOrktetEfhfuw=="], - "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wxPvu4XOA85YJk9ixSWUmq/QBHbid85BISbOAqqBM/5xQpPk9ayjk5375tOlSC0BeCwNSbPFafQBm+vBumXq0A=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Mn3Fwi3SA5fgmfCPqmzpWF2DLZnms3BVAhM088nTnGrTZmHS3wwIjcoZPqpXeNgd3DrrLH6xp8vTLIBuJoZiXw=="], - "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-xFCqGPwYusQJp4N4NJLi1XJiZqjwFdjhT+KqtNy+Ug3qgfczqnTa6MSDvxJF6TkuDLoYJItMapz6tAf7kCekFw=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rg3VPL5P8mYro6pqlXYXuJWph21slVp3SZtAqWSrkZs40d2gTzYmHF8E/X1iTID25btmNKltNDJ926sqVBp7DQ=="], - "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-2kFb4//jxfZaP6D+Rj5VkHkxgyD9EoRAVBEQb8PKRv+s4NO2zYNJKXFaJmK1CmhufJOWEfpHKaRbOja7qjmdhQ=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-tl+LW8fdD96/xdeWtWwc82LIOc5CoY7N2AsogLTp5R4ECErYt+8Jl/N68ezN9vzSiqPTxw6vjcihoLPYKZHrlw=="], - "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-oYxnW0ARfJkr72ezzF2OR8N/rtkgLUQeYtF8cFhVswbknHxtTcmzSsanVJP8yQKnGpGpc2ck6c5zLvHahL6Cbg=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-vQdM4oSGaf7ZNeGO9w5+Y8SBtyser9M6znxYbm7Ec8wInxJu1WiKxFYZW5Auj2d80bcVvefuGGRxoFOE0eee8g=="], - "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-NbcBbi/nJqn5baae6wqRXdS7Gadf2uRpehSh6vMSYpG8OhkXl/Xg8aorWrJ+9VWqAT5ml90alLvorkpMW0nBwQ=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-zpEGf4RQbFEh8Vt7OmavLyyOzRbtcE9osCqrS1kfvt8jDvxwhKXLSf7n0ebr/ov0RJ9ssP+lhs6C8a9WwFvrQA=="], - "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.16", "", { "os": "linux", "cpu": "x64" }, "sha512-iHDS+MCM65DPqWGu+ECC3uoALyj2H7F4nVUPxIPjz/PIl94EUu+EDfGZDzFP+NY1EOPVt9NQvwFqq7HdMmowdg=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-+9hIcMngJ+yGUahXqZuZ8CoWKJE9SAZsFsM3QDvXpNsLbXZ9lqVzgBhOk/jTSYkOA0GLP9eu3teukqpLUojHMg=="], - "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-0rgImMsNb5v/chhkIFe3wu7PEFClS6RBAYUijGL9UsYN3PanSaoK24HSSuSJb1pYbYYVjzAyZTl3gtjJ84BM8A=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-jB0wAvTLI4itx5VidqVUejPQFhRUxiZ9l9FvZ26D5fl6t3qme+ZB4PD3bTSeL1vZ8NI2Rx/zj6H9zcESuGHKGw=="], - "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Kp85jgoBHa05gix6UIRjfCDiUV3w/8VIdZ247VyyO2gEjaw12WEVhdIjlxp/AMzXxqxQwbxNTDVZ3Mwd2RG5rw=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VT/lF+GId+67j8aDfLkxdxNoVApsPSTbyAtB3jJq0IWTrY77WXfbPfpngxq0bA6JCEv/7k8C9qWjDRKRznDlyw=="], "@inquirer/ansi": ["@inquirer/ansi@2.0.7", "", {}, "sha512-3eTuUO1vH2cZm2ZKHeQxnOqlTi9EfZDGgIe3BL3I4u+rJHocr9Fz86M4fjYABPvFnQG/gGK551HqDiIcETwU6Q=="], diff --git a/package.json b/package.json index a018c18..7623789 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "zod": "4.4.3" }, "devDependencies": { - "@biomejs/biome": "2.4.16", + "@biomejs/biome": "2.5.0", "@types/bun": "1.3.14", "typescript": "6.0.3" } diff --git a/src/hooks/promote-memory.ts b/src/hooks/promote-memory.ts index 792c967..bc9e71b 100644 --- a/src/hooks/promote-memory.ts +++ b/src/hooks/promote-memory.ts @@ -8,13 +8,12 @@ // break a tool call). import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import { basename } from "node:path"; import { parseFrontmatter } from "../lib/frontmatter.ts"; import { readHookInput, runHook } from "../lib/hook-runtime.ts"; +import { claudePath } from "../lib/platform.ts"; -const CLAUDE_DIR = join(homedir(), ".claude"); -const SEEN_SET_PATH = join(CLAUDE_DIR, ".cache", "share-nudge-seen.json"); +const SEEN_SET_PATH = claudePath(".cache", "share-nudge-seen.json"); const TEAM_RELEVANT_TYPES = new Set(["project", "feedback"]); @@ -29,7 +28,7 @@ async function readSeenSet(): Promise { } async function writeSeenSet(seen: string[]): Promise { - await mkdir(join(CLAUDE_DIR, ".cache"), { recursive: true }); + await mkdir(claudePath(".cache"), { recursive: true }); await writeFile(SEEN_SET_PATH, JSON.stringify(seen)); } diff --git a/src/hooks/safety-net.ts b/src/hooks/safety-net.ts index 97a2b13..b1f37d5 100644 --- a/src/hooks/safety-net.ts +++ b/src/hooks/safety-net.ts @@ -13,12 +13,12 @@ // docs/hooks-reference.md). import { appendFile, mkdir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; import { blockDecision } from "../lib/hook-runtime.ts"; +import { claudePath, isoNow } from "../lib/platform.ts"; const HOOK_VERSION = "1.0.0"; -const LOG_FILE = join(homedir(), ".claude", "safety-net.log"); +const LOG_FILE = claudePath("safety-net.log"); // --- Utilities ------------------------------------------------------------ @@ -35,7 +35,7 @@ function redactSecrets(text: string): string { async function logBlocked(cmd: string, reason: string): Promise { const redacted = redactSecrets(cmd); - const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const timestamp = isoNow(); const cwd = process.cwd() || "unknown"; const line = `${JSON.stringify({ timestamp, diff --git a/src/hooks/statusline.ts b/src/hooks/statusline.ts index 5760f58..d0afc60 100644 --- a/src/hooks/statusline.ts +++ b/src/hooks/statusline.ts @@ -11,6 +11,7 @@ import { existsSync } from "node:fs"; import { basename } from "node:path"; import { readCodexVerdict } from "../lib/codex.ts"; +import { palette } from "../lib/colors.ts"; import { runGit as runGitLib } from "../lib/git.ts"; import { readHookInput, readState } from "../lib/hook-runtime.ts"; import { ageMs, formatAge, maxUnreviewed, type ReviewQueueState } from "../lib/review-queue.ts"; @@ -77,11 +78,8 @@ async function buildGitStatus(cwd: string): Promise { ]); if (!branch) return null; - const cyan = "\x1b[36m"; - const yellow = "\x1b[33m"; - const reset = "\x1b[0m"; - - const dirty = dirtyUnstaged !== 0 || dirtyStaged !== 0 ? `${yellow}✱${reset}` : ""; + const dirty = + dirtyUnstaged !== 0 || dirtyStaged !== 0 ? `${palette.yellow}✱${palette.reset}` : ""; let upstream = ""; if (counts) { @@ -90,7 +88,7 @@ async function buildGitStatus(cwd: string): Promise { if (Number(behind) > 0) upstream += "↓"; } - return `${cyan}${branch}${reset}${dirty}${upstream}`; + return `${palette.cyan}${branch}${palette.reset}${dirty}${upstream}`; } function formatTimeToReset(value: number | string): string | null { @@ -116,7 +114,7 @@ function formatTimeToReset(value: number | string): string | null { return h > 0 ? `${h}h${m.toString().padStart(2, "0")}m` : `${m}m`; } -const dimSep = "\x1b[2m | \x1b[0m"; +const dimSep = `${palette.dim} | ${palette.reset}`; // Degraded-path capture: filled as soon as the payload parses, so the catch // block at the bottom can still print the model/cwd segment. @@ -148,9 +146,9 @@ async function main(): Promise { // `+` = thinking enabled. Used `+` instead of `†` (dagger) because the dagger // glyph reads as a "t" in many monospace terminal fonts, making "xhigh†" look // like "xhight". - const dim = "\x1b[2m"; - const reset = "\x1b[0m"; - const marker = effortLevel ? `${dim} ⚙${effortLevel}${thinkingEnabled ? "+" : ""}${reset}` : ""; + const marker = effortLevel + ? `${palette.dim} ⚙${effortLevel}${thinkingEnabled ? "+" : ""}${palette.reset}` + : ""; parts.push(`${model}${marker}`); } if (dirName) parts.push(dirName); @@ -166,15 +164,10 @@ async function main(): Promise { if (rateUsed !== undefined) { const rInt = Math.round(rateUsed); - const red = "\x1b[31m"; - const yellow = "\x1b[33m"; - const green = "\x1b[32m"; - const dim = "\x1b[2m"; - const reset = "\x1b[0m"; - const color = rInt >= 80 ? red : rInt >= 50 ? yellow : green; + const color = rInt >= 80 ? palette.red : rInt >= 50 ? palette.yellow : palette.green; const ttr = rateResetsAt ? formatTimeToReset(rateResetsAt) : null; - const suffix = ttr ? `${dim} ↻${ttr}${reset}` : ""; - parts.push(`${color}⚡${rInt}%${reset}${suffix}`); + const suffix = ttr ? `${palette.dim} ↻${ttr}${palette.reset}` : ""; + parts.push(`${color}⚡${rInt}%${palette.reset}${suffix}`); } // Review-queue backpressure: agents spawned since the last commit, awaiting @@ -182,13 +175,10 @@ async function main(): Promise { // under the threshold, red at/over CC_MAX_UNREVIEWED. const reviewQueue = await readState("review-queue.json", { awaiting: 0 }); if (reviewQueue.awaiting > 0) { - const yellow = "\x1b[33m"; - const red = "\x1b[31m"; - const reset = "\x1b[0m"; - const color = reviewQueue.awaiting >= maxUnreviewed() ? red : yellow; + const color = reviewQueue.awaiting >= maxUnreviewed() ? palette.red : palette.yellow; const age = ageMs(reviewQueue, Date.now()); const ageLabel = age > 0 ? ` (${formatAge(age)})` : ""; - parts.push(`${color}⚠ ${reviewQueue.awaiting} review${ageLabel}${reset}`); + parts.push(`${color}⚠ ${reviewQueue.awaiting} review${ageLabel}${palette.reset}`); } // cc-settings install staleness — surfaced only when the cached SessionStart @@ -199,29 +189,21 @@ async function main(): Promise { { stale: false }, ); if (drift.stale && drift.installed) { - const yellow = "\x1b[33m"; - const dim = "\x1b[2m"; - const reset = "\x1b[0m"; - parts.push(`${yellow}⬆ cc v${drift.installed}${dim} stale${reset}`); + parts.push(`${palette.yellow}⬆ cc v${drift.installed}${palette.dim} stale${palette.reset}`); } // Codex bridge availability badge — reads the cached verdict written by // codex-verify.ts at SessionStart (no spawn here, hot-path safe). // "not-installed" and "unknown" → silent (no clutter for teammates without Codex). const codexVerdict = await readCodexVerdict(); - { - const green = "\x1b[32m"; - const yellow = "\x1b[33m"; - const reset = "\x1b[0m"; - if (codexVerdict.state === "available") { - parts.push(`${green}codex ✓${reset}`); - } else if (codexVerdict.state === "unauthenticated" || codexVerdict.state === "no-access") { - parts.push(`${yellow}codex auth?${reset}`); - } else if (codexVerdict.state === "rate-limited") { - parts.push(`${yellow}codex ⏳${reset}`); - } - // "not-installed" | "unknown" → push nothing + if (codexVerdict.state === "available") { + parts.push(`${palette.green}codex ✓${palette.reset}`); + } else if (codexVerdict.state === "unauthenticated" || codexVerdict.state === "no-access") { + parts.push(`${palette.yellow}codex auth?${palette.reset}`); + } else if (codexVerdict.state === "rate-limited") { + parts.push(`${palette.yellow}codex ⏳${palette.reset}`); } + // "not-installed" | "unknown" → push nothing process.stdout.write(`${parts.join(dimSep)}\n`); } diff --git a/src/lib/audit-hooks.ts b/src/lib/audit-hooks.ts index 8945b60..f785937 100644 --- a/src/lib/audit-hooks.ts +++ b/src/lib/audit-hooks.ts @@ -31,12 +31,12 @@ // nothing found → exit 0 import { existsSync } from "node:fs"; -import { readFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import { HooksBlock } from "../schemas/hooks.ts"; import { parseHookCommand } from "./hook-command.ts"; import { hashFileOrNull, readSrcManifest } from "./hooks-fingerprint.ts"; +import { readJsonOrNull } from "./json-io.ts"; +import { CLAUDE_DIR } from "./platform.ts"; export type HookSeverity = "trusted" | "unknown" | "stale" | "suspicious"; @@ -88,7 +88,7 @@ export interface SrcIntegrity { * on any read error - fail-soft, never throws. */ export async function loadSrcIntegrity(claudeDir?: string): Promise { try { - const dir = claudeDir ?? join(homedir(), ".claude"); + const dir = claudeDir ?? CLAUDE_DIR; const manifest = await readSrcManifest(dir); if (!manifest) return null; const files = new Map(); @@ -317,15 +317,16 @@ export function auditHooks(settings: unknown, integrity?: SrcIntegrity | null): export const HOOKS_SCHEMA_VALIDATION_FAILED = "hooks config failed schema validation"; export async function auditSettingsFile(path?: string, claudeDir?: string): Promise { - const settingsPath = path ?? join(homedir(), ".claude", "settings.json"); + const settingsPath = path ?? join(CLAUDE_DIR, "settings.json"); if (!existsSync(settingsPath)) { return { settingsPath, exists: false, totalHooks: 0, findings: [] }; } - let parsed: unknown = null; - try { - const text = await readFile(settingsPath, "utf8"); - parsed = JSON.parse(text); - } catch { + + // Use canonical readJsonOrNull for ENOENT-vs-parse distinction and + // JsonParseError wrapping — the one settings.json reader that previously + // bypassed this. Returns null on ENOENT (already guarded above) or bad JSON. + const parsed = await readJsonOrNull(settingsPath).catch(() => null); + if (parsed === null) { // Malformed JSON - return empty audit (this is not the file we're trying // to defend against; a broken file is its own problem). return { settingsPath, exists: true, totalHooks: 0, findings: [] }; @@ -338,6 +339,7 @@ export async function auditSettingsFile(path?: string, claudeDir?: string): Prom // alone doesn't prove malice - it might be forward-compat drift from a newer // Claude Code version. const extraFindings: SchemaFinding[] = []; + let validatedHooks: Record | undefined; if (parsed !== null && typeof parsed === "object") { const hooksRaw = (parsed as Record).hooks; if (hooksRaw !== undefined) { @@ -355,6 +357,9 @@ export async function auditSettingsFile(path?: string, claudeDir?: string): Prom severity: "unknown", reasons: [issueSummary], }); + } else { + // §3.2: pass validated hooks data so auditHooks doesn't repeat defensive checks + validatedHooks = schemaResult.data as Record; } } } @@ -364,9 +369,16 @@ export async function auditSettingsFile(path?: string, claudeDir?: string): Prom // setup.ts installed. const integrity = await loadSrcIntegrity(claudeDir); + // §3.2: When schema validation passed, inject the validated hooks block so + // auditHooks receives typed data and skips redundant typeof guards. + const auditInput = + validatedHooks !== undefined + ? { ...(parsed as Record), hooks: validatedHooks } + : parsed; + // totalHooks counts audited command hooks (the "hook command(s) total" the // CLI prints) - NOT findings, which also include the schema pseudo-finding. - const hookFindings = auditHooks(parsed, integrity); + const hookFindings = auditHooks(auditInput, integrity); const findings: AuditFinding[] = [...extraFindings, ...hookFindings]; return { settingsPath, exists: true, totalHooks: hookFindings.length, findings }; } diff --git a/src/lib/hook-runtime.ts b/src/lib/hook-runtime.ts index 2f3db08..eb6678d 100644 --- a/src/lib/hook-runtime.ts +++ b/src/lib/hook-runtime.ts @@ -3,10 +3,10 @@ // state IO, top-level fail-open wrapper. Extracted in v11.1.1 — see CHANGELOG. import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { claudePath } from "./platform.ts"; -const TMP_DIR = join(homedir(), ".claude", "tmp"); +const TMP_DIR = claudePath("tmp"); /** Read a stdin JSON payload; on parse failure, fall back to env. * diff --git a/src/lib/hooks-fingerprint.ts b/src/lib/hooks-fingerprint.ts index a23188c..ca32b46 100644 --- a/src/lib/hooks-fingerprint.ts +++ b/src/lib/hooks-fingerprint.ts @@ -11,12 +11,12 @@ import { existsSync } from "node:fs"; import { readdir, readFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; import { CryptoHasher } from "bun"; import { z } from "zod"; import { iterCommandHooks } from "./hook-command.ts"; import { atomicWriteJson } from "./json-io.ts"; +import { CLAUDE_DIR } from "./platform.ts"; // `installedAt` is echoed verbatim into the terminal warning banner, and the // fingerprint file is exactly what the Shai-Hulud threat model lets an attacker @@ -69,7 +69,7 @@ export interface FingerprintRecord { } export async function readFingerprint(claudeDir?: string): Promise { - const path = join(claudeDir ?? join(homedir(), ".claude"), FINGERPRINT_FILENAME); + const path = join(claudeDir ?? CLAUDE_DIR, FINGERPRINT_FILENAME); if (!existsSync(path)) return null; try { const text = await readFile(path, "utf8"); @@ -89,7 +89,7 @@ export async function writeFingerprint( settings: unknown, claudeDir?: string, ): Promise { - const dir = claudeDir ?? join(homedir(), ".claude"); + const dir = claudeDir ?? CLAUDE_DIR; const path = join(dir, FINGERPRINT_FILENAME); // Count command hooks via the shared iterCommandHooks walk (fail-open: @@ -117,7 +117,7 @@ export async function verifyAgainstSettings( settingsPath?: string, claudeDir?: string, ): Promise { - const dir = claudeDir ?? join(homedir(), ".claude"); + const dir = claudeDir ?? CLAUDE_DIR; const sPath = settingsPath ?? join(dir, "settings.json"); if (!existsSync(sPath)) { return { status: "missing-settings", expected: null, actual: null, installedAt: null }; @@ -172,6 +172,24 @@ export interface SrcManifestRecord { installedAt: string; } +// Zod schema for the src manifest written by setup.ts. Validates on read to +// close the Shai-Hulud attack vector: a tampered manifest must not point +// outside the src tree or carry non-string hashes. The .refine() enforces the +// path-traversal guard; sibling FingerprintRecordSchema does the same for the +// hooks fingerprint. +const SrcManifestRecordSchema = z.object({ + files: z + .record(z.string(), z.string()) + .refine( + (files) => + Object.entries(files).every( + ([rel]) => !rel.startsWith("/") && !rel.split(/[/\\]/).includes(".."), + ), + { message: "manifest contains path traversal" }, + ), + installedAt: z.string().default("").transform(stripControl), +}); + /** SHA256 hex of a file's content, or null when it can't be read. */ export async function hashFileOrNull(path: string): Promise { try { @@ -209,7 +227,7 @@ export async function writeSrcManifest( installedSrcDir: string, claudeDir?: string, ): Promise { - const dir = claudeDir ?? join(homedir(), ".claude"); + const dir = claudeDir ?? CLAUDE_DIR; const files: Record = {}; for (const rel of await walkTsFiles(installedSrcDir)) { const hash = await hashFileOrNull(join(installedSrcDir, rel)); @@ -221,23 +239,16 @@ export async function writeSrcManifest( } export async function readSrcManifest(claudeDir?: string): Promise { - const path = join(claudeDir ?? join(homedir(), ".claude"), SRC_MANIFEST_FILENAME); + const path = join(claudeDir ?? CLAUDE_DIR, SRC_MANIFEST_FILENAME); if (!existsSync(path)) return null; try { - const parsed = JSON.parse(await readFile(path, "utf8")) as Partial; - if (!parsed.files || typeof parsed.files !== "object" || Array.isArray(parsed.files)) { - return null; - } - // Each key is later join()'d under ~/.claude/src and read — a tampered - // manifest must not point outside the tree or carry non-string hashes. - // Reject the whole manifest (fail-open to "missing") if either holds. - const files: Record = {}; - for (const [rel, hash] of Object.entries(parsed.files)) { - if (typeof hash !== "string") return null; - if (rel.startsWith("/") || rel.split(/[/\\]/).includes("..")) return null; - files[rel] = hash; - } - return { files, installedAt: stripControl(parsed.installedAt ?? "") }; + const raw = JSON.parse(await readFile(path, "utf8")); + // Route through SrcManifestRecordSchema: validates types, rejects path + // traversal (the .refine()), and strips control chars from installedAt. + // Mirrors readFingerprint's FingerprintRecordSchema.safeParse pattern. + const result = SrcManifestRecordSchema.safeParse(raw); + if (!result.success) return null; + return result.data; } catch { return null; } @@ -255,7 +266,7 @@ export interface SrcVerifyResult { * manifest yet (pre-manifest install) — callers treat that as a soft state, * not an alarm. Any read error on an individual file counts as changed. */ export async function verifySrcManifest(claudeDir?: string): Promise { - const dir = claudeDir ?? join(homedir(), ".claude"); + const dir = claudeDir ?? CLAUDE_DIR; const manifest = await readSrcManifest(dir); if (!manifest) return { status: "missing", changed: [], unmanifested: [] }; diff --git a/src/lib/install-cmds.ts b/src/lib/install-cmds.ts new file mode 100644 index 0000000..0a753cd --- /dev/null +++ b/src/lib/install-cmds.ts @@ -0,0 +1,79 @@ +// Install command helpers — extracted from src/setup.ts (§1.1). +// +// One-shot commands that run before the main install logic: +// printHelp — usage text +// cmdRollback — restore a backup archive + +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { homedir } from "node:os"; +import { error, info, success } from "./colors.ts"; +import { CLAUDE_DIR } from "./platform.ts"; + +export function printHelp(version: string): void { + console.log(`cc-settings installer v${version} + +Usage: bun src/setup.ts [flags] + +Flags: + --source= Source repo path (default: parent of setup.ts). + --rollback[=TS] Restore newest backup, or one matching timestamp TS. + --dry-run Print planned actions; do not touch disk. + --light Install raw Claude Code + statusLine + share-learning only: + • skills: share-learning (only) + • settings.json: $schema + statusLine only + • no MCP servers, no hooks, no effort override + • no CLAUDE.md, AGENTS.md, agents, rules, profiles, + docs, or permission rules + Re-run without --light to upgrade to full. + --status Report installed version, drift vs repo HEAD, missing + managed skills, hooks, key env vars, and MCP servers. + --interactive Prompt on settings.json conflicts (scalar overrides, team + additions to allow/ask rules, new hook groups). Also opt in + via CC_INTERACTIVE=1. + --migrate-only Run only the settings.json merger + version sentinel; + skip file copy, dependency install, and skill/agent + refresh. Use after a cc-settings update if you only + want the merger's deprecation prune to apply. + --help, -h Show this message. + +Rollback examples: + bun src/setup.ts --rollback + bun src/setup.ts --rollback=2026-04-20T10-00-00Z`); +} + +export async function cmdRollback(target: string | true): Promise { + const backupDir = `${CLAUDE_DIR}/backups`; + if (!existsSync(backupDir)) { + error(`No backups directory found at ${backupDir}`); + return 1; + } + const entries = (await readdir(backupDir)) + .filter((e) => /^backup-.*\.tar\.gz$/.test(e)) + .sort() + .reverse(); + const match = target === true ? entries[0] : entries.find((e) => e.includes(target)); + if (!match) { + error("No matching backup found."); + console.error("Available backups:"); + for (const e of entries.slice(0, 5)) console.error(` ${e}`); + return 1; + } + info(`Rolling back from: ${match}`); + const archivePath = `${backupDir}/${match}`; + // Newer archives are $HOME-relative (entries prefixed with ".claude/", plus a + // top-level ".claude.json"); pre-MCP-backup archives are ~/.claude-relative + // (bare "settings.json"). Detect the layout so each restores to the right place. + const listing = Bun.spawn(["tar", "-tzf", archivePath], { stdout: "pipe", stderr: "ignore" }); + const archiveEntries = (await new Response(listing.stdout).text()).trim().split("\n"); + await listing.exited; + const homeRelative = archiveEntries.some((e) => e.startsWith(".claude/") || e === ".claude.json"); + const proc = Bun.spawn(["tar", "-xzf", archivePath], { + cwd: homeRelative ? homedir() : CLAUDE_DIR, + stdout: "inherit", + stderr: "inherit", + }); + const code = await proc.exited; + if (code === 0) success("Restored. Restart Claude Code to apply."); + return code; +} diff --git a/src/lib/install-display.ts b/src/lib/install-display.ts new file mode 100644 index 0000000..2e28a34 --- /dev/null +++ b/src/lib/install-display.ts @@ -0,0 +1,228 @@ +// Install display helpers — extracted from src/setup.ts (§1.1). +// +// Pure output rendering: countEntries, showSummary, cmdDryRun, printStatus. +// No coupling to install execution; import them from setup.ts's install phases. + +import { existsSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { boxEnd, boxLine, boxStart, palette, success, warn } from "./colors.ts"; +import { readJsonOrNull } from "./json-io.ts"; +import { LIGHT_SKILLS, PROFILE_MANIFEST } from "./light-profile.ts"; +import { CLAUDE_JSON_PATH } from "./mcp.ts"; +import { CLAUDE_DIR } from "./platform.ts"; +import type { StatusData } from "./status-types.ts"; + +export async function countEntries(dir: string, pattern: RegExp): Promise { + const full = join(CLAUDE_DIR, dir); + if (!existsSync(full)) return 0; + try { + const entries = await readdir(full); + return entries.filter((e) => pattern.test(e)).length; + } catch { + return 0; + } +} + +export async function showSummary(profile: "full" | "light"): Promise { + const profileLabel = profile === "light" ? " [light]" : ""; + console.log(""); + boxStart(`Installed${profileLabel}`); + if (profile === "light") { + boxLine("ok", "settings.json ($schema + statusLine only)"); + for (const skill of LIGHT_SKILLS) boxLine("ok", `skills/${skill}`); + boxLine("ok", "src/ (TS; statusLine + libs)"); + boxLine("ok", "memory/"); + } else { + // Rendered from PROFILE_MANIFEST so the summary can't drift from what + // installConfigFiles actually copies. Dirs with installed .md files show + // a count; container dirs (skills/) just list. + const ROOT_FILE_LABELS: Record = { + "CLAUDE.md": "(Claude-Code config)", + "AGENTS.md": "(portable standards)", + }; + const manifest = PROFILE_MANIFEST.full; + for (const [, dest] of manifest.rootFiles) { + const label = ROOT_FILE_LABELS[dest]; + boxLine("ok", label ? `${dest} ${label}` : dest); + } + boxLine("ok", "settings.json (TS hooks)"); + boxLine("ok", "~/.claude.json (MCP servers)"); + const counts = await Promise.all(manifest.dirs.map((d) => countEntries(d, /\.md$/))); + manifest.dirs.forEach((d, i) => { + const n = counts[i] ?? 0; + boxLine("ok", n > 0 ? `${d}/ (${n})` : `${d}/`); + }); + boxLine("ok", "src/ (TS; hooks + scripts + libs + schemas)"); + boxLine("ok", "memory/"); + } + boxEnd(); + + if (profile === "light") { + console.log(""); + console.log( + `${palette.dim}Light profile: raw Claude Code · statusLine · share-learning skill only${palette.reset}`, + ); + console.log( + `${palette.dim}No CLAUDE.md, AGENTS.md, MCP servers, hooks, or effort override.${palette.reset}`, + ); + console.log(`${palette.dim}Re-run without --light to upgrade to full.${palette.reset}`); + } + + const claudeJson = (await readJsonOrNull(CLAUDE_JSON_PATH)) as { + mcpServers?: Record; + } | null; + const servers = Object.entries(claudeJson?.mcpServers ?? {}); + if (servers.length > 0) { + console.log(""); + console.log(`${palette.bold}MCP servers in ~/.claude.json:${palette.reset}`); + // Group by `_status` annotation. Servers without a status are listed as + // "user-added" — they came from the user's machine, not the team config. + const core: string[] = []; + const optional: string[] = []; + const userAdded: string[] = []; + for (const [name, server] of servers) { + const status = (server as { _status?: unknown })._status; + if (status === "core") core.push(name); + else if (status === "optional") optional.push(name); + else userAdded.push(name); + } + if (core.length > 0) { + console.log(` ${palette.dim}core:${palette.reset}`); + for (const s of core) console.log(` - ${s}`); + } + if (optional.length > 0) { + console.log(` ${palette.dim}optional (manually added):${palette.reset}`); + for (const s of optional) console.log(` - ${s}`); + } + if (userAdded.length > 0) { + console.log(` ${palette.dim}user-added:${palette.reset}`); + for (const s of userAdded) console.log(` - ${s}`); + } + } +} + +export async function cmdDryRun( + source: string, + profile: "full" | "light", + version: string, +): Promise { + const profileLabel = profile === "light" ? " [light profile]" : ""; + console.log(`cc-settings installer v${version} — dry-run${profileLabel}`); + console.log(`source: ${source}`); + console.log(`target: ${CLAUDE_DIR}`); + console.log(""); + + if (profile === "light") { + console.log("Would install (light = raw Claude Code + statusLine + share-learning):"); + const items: Array<[string, string]> = [ + ...LIGHT_SKILLS.map((s): [string, string] => [`skills/${s}/`, `→ ~/.claude/skills/${s}/`]), + ["src/", "→ ~/.claude/src/ (all TS)"], + ["config/", "→ ~/.claude/settings.json ($schema + statusLine only)"], + ]; + for (const [rel, effect] of items) { + const mark = existsSync(join(source, rel)) ? "✓" : " "; + console.log(` ${mark} ${rel.padEnd(28)} ${effect}`); + } + console.log(""); + console.log("Light profile: no CLAUDE.md · no AGENTS.md · no MCP servers · no hooks"); + console.log(" no agents · no rules · no profiles · no docs"); + console.log(" default Claude Code permissions · default effort"); + } else { + console.log("Would install:"); + // Rendered from PROFILE_MANIFEST so the dry-run table can't drift from + // what installConfigFiles actually copies. + const items: Array<[string, string]> = [ + ...PROFILE_MANIFEST.full.rootFiles.map(([src, dest]): [string, string] => [ + src, + `→ ~/.claude/${dest}`, + ]), + ["config/", "→ ~/.claude/settings.json (composed + MCP-merged)"], + ["src/", "→ ~/.claude/src/ (all TS)"], + ...PROFILE_MANIFEST.full.dirs.map((d): [string, string] => [`${d}/`, `→ ~/.claude/${d}/`]), + ]; + for (const [rel, effect] of items) { + const mark = existsSync(join(source, rel)) ? "✓" : " "; + console.log(` ${mark} ${rel.padEnd(22)} ${effect}`); + } + } + + console.log(""); + console.log("No files written. Re-run without --dry-run to install."); +} + +export function printStatus(data: StatusData): void { + console.log("cc-settings --status"); + console.log(""); + + // Installed version + if (data.sentinel.version) { + const profileLabel = data.sentinel.profile ? ` [${data.sentinel.profile}]` : ""; + console.log( + ` installed: v${data.sentinel.version}${profileLabel} (${data.sentinel.installedAt ?? "unknown"})`, + ); + } else { + console.log( + ` installed: ${palette.yellow}none${palette.reset} (no sentinel at ~/.claude/.cc-settings-version)`, + ); + } + console.log(` packaged: v${data.packagedVersion}`); + + // Git drift + if (data.git?.sha) { + const g = data.git; + const driftNote = + g.behind === null + ? "(sentinel absent — can't compute drift)" + : g.behind === 0 + ? `${palette.green}up to date${palette.reset}` + : `${palette.yellow}${g.behind} commit(s) since install${palette.reset}`; + console.log(` repo HEAD: ${g.sha} ${driftNote}`); + } + + console.log(""); + console.log("Managed skills:"); + console.log(` present: ${data.skills.presentCount}/${data.skills.shippedCount}`); + if (data.skills.missing.length > 0) { + console.log(` missing: ${data.skills.missing.join(", ")}`); + } + + console.log(""); + console.log("Hooks:"); + console.log( + ` events registered: ${data.hooks.events.length} (${data.hooks.groupCount} group(s) total)`, + ); + if (data.hooks.events.length > 0) { + console.log(` ${data.hooks.events.sort().join(", ")}`); + } + + console.log(""); + console.log("Env vars:"); + for (const { key, value } of data.envVars) { + const mark = + value === undefined + ? `${palette.yellow}✗${palette.reset}` + : `${palette.green}✓${palette.reset}`; + const val = value === undefined ? "(unset)" : value; + console.log(` ${mark} ${key}=${val}`); + } + + console.log(""); + console.log("Permissions:"); + console.log(` allow: ${data.permissions.allowCount} deny: ${data.permissions.denyCount}`); + + console.log(""); + console.log("MCP servers:"); + const { servers } = data.mcp; + console.log( + ` configured: ${servers.length}${servers.length > 0 ? ` (${servers.join(", ")})` : ""}`, + ); + + console.log(""); + + if (data.warnings.length === 0) { + success("all checks passed"); + } else { + for (const { message } of data.warnings) warn(message); + } +} diff --git a/src/lib/light-profile.ts b/src/lib/light-profile.ts index 0863c38..cd2c1e1 100644 --- a/src/lib/light-profile.ts +++ b/src/lib/light-profile.ts @@ -25,9 +25,9 @@ export const LIGHT_SKILLS: readonly string[] = ["share-learning"] as const; // --------------------------------------------------------------------------- // // Consumed by src/setup.ts: -// - installConfigFiles copies rootFiles + dirs for the profile -// - removeLightIncompatibleFiles removes full-minus-light on a light install -// - cmdDryRun / showSummary render the install tables from it +// - installConfigFiles copies the profile's files; for light, also prunes +// every full-only target (full-minus-light) +// - cmdDryRun / showSummary render the install tables from it // // The light skill-filter (LIGHT_SKILLS subset + source-scoped prune) stays as // code in setup.ts — only the file/dir lists live here. diff --git a/src/lib/mcp.ts b/src/lib/mcp.ts index 4492f33..9d09101 100644 --- a/src/lib/mcp.ts +++ b/src/lib/mcp.ts @@ -36,7 +36,7 @@ import { debug, error, info, success, warn } from "./colors.ts"; import { atomicWriteJson, readJsonOrNull } from "./json-io.ts"; import { asRecord, subtractByKey } from "./merge-keyed.ts"; import { promptYn } from "./prompts.ts"; -import { type MergeOptions, mergeSettings } from "./settings-merge.ts"; +import { type MergeAccounting, type MergeOptions, mergeSettings } from "./settings-merge.ts"; type McpServer = z.infer[string]; export type McpServers = Record; @@ -236,7 +236,7 @@ export async function mergeSettingsWithMcpPreservation( teamSettings: Record, outputPath: string, opts: MergeOptions = {}, -): Promise { +): Promise { // Peek at the user's existing file to extract current mcpServers so we can // run the preservation prompt before the per-key merge loop. // readJsonOrNull throws on unparseable JSON (JsonParseError) — honored here @@ -245,8 +245,7 @@ export async function mergeSettingsWithMcpPreservation( if (!userRaw) { // No existing file — delegate directly; the pure merger writes team as-is. - await mergeSettings(existingPath, teamSettings, outputPath, opts); - return; + return mergeSettings(existingPath, teamSettings, outputPath, opts); } // asRecord: a corrupt string-valued mcpServers degrades to {} instead of @@ -259,5 +258,5 @@ export async function mergeSettingsWithMcpPreservation( // Delegate to the pure merger, supplying the already-resolved mcpServers. // The pure merger skips the mcpServers key in its per-key strategy loop and // uses the value we computed here instead. - await mergeSettings(existingPath, teamSettings, outputPath, opts, resolvedMcp); + return mergeSettings(existingPath, teamSettings, outputPath, opts, resolvedMcp); } diff --git a/src/lib/platform.ts b/src/lib/platform.ts index a4563de..9a80a3b 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -6,6 +6,8 @@ // stay local. import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; export type OS = "macos" | "linux" | "wsl" | "windows" | "unknown"; @@ -58,3 +60,27 @@ export function ymd(d: Date = new Date()): string { export function hasCommand(cmd: string): boolean { return Bun.which(cmd) !== null; } + +// Canonical ~/.claude directory. All code should use this rather than +// joining homedir() + ".claude" inline — makes root overridable in tests +// and keeps the derivation in one place. +export const CLAUDE_DIR = join(homedir(), ".claude"); + +// Join one or more path segments under CLAUDE_DIR. +export function claudePath(...segments: string[]): string { + return join(CLAUDE_DIR, ...segments); +} + +// ISO-8601 timestamp without milliseconds: "2026-06-19T12:34:56Z". +// Replaces the five inline `new Date().toISOString().replace(/\.\d{3}Z$/, "Z")` +// idioms scattered across scripts and hooks. +export function isoNow(): string { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} + +// YYYY-MM-DD HH:MM:SS in local time — bash parity: `date '+%Y-%m-%d %H:%M:%S'`. +// Replaces the three private formatters (formatTimestamp/formatDate/hms) that +// produced identical output across stop-failure.ts, session-start.ts, log-bash.ts. +export function localDatetime(d: Date = new Date()): string { + return `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; +} diff --git a/src/lib/settings-merge.ts b/src/lib/settings-merge.ts index a45f9b5..32b5ff6 100644 --- a/src/lib/settings-merge.ts +++ b/src/lib/settings-merge.ts @@ -498,7 +498,7 @@ export async function mergeSettings( outputPath: string, opts: MergeOptions = {}, resolvedMcpServers?: Record, -): Promise { +): Promise { const userRaw = (await readJsonOrNull(existingPath)) as UnknownRecord | null; // No existing file → write team as-is (atomic), injecting resolvedMcpServers @@ -509,7 +509,7 @@ export async function mergeSettings( ? { ...teamSettings, mcpServers: resolvedMcpServers } : teamSettings; await atomicWriteJson(outputPath, out); - return; + return null; } // Validate userRaw against the Settings schema. On failure we log a debug @@ -561,8 +561,16 @@ export async function mergeSettings( if (result.keep) merged[key] = result.value; } - // --- Build the user-facing summary --- - const a = ctx.accounting; + await atomicWriteJson(outputPath, merged); + // Return accounting so the caller (installSettings in setup.ts) can print + // the user-facing summary — §3.4: mergeSettings is a pure orchestrator. + return ctx.accounting; +} + +/** Print the merge accounting summary. Called by installSettings after + * mergeSettingsWithMcpPreservation returns. Separated from mergeSettings + * so the orchestrator stays side-effect free (§3.4). */ +export function printMergeAccounting(a: MergeAccounting, opts: MergeOptions = {}): void { const bits: string[] = []; if (a.permissionsAdded > 0) bits.push(`${a.permissionsAdded} permission rule(s)`); if (a.hooksAdded > 0) bits.push(`${a.hooksAdded} hook group(s)`); @@ -591,6 +599,4 @@ export async function mergeSettings( if (adopted > 0) ibits.push(`${adopted} team value(s) adopted over yours`); if (ibits.length > 0) info(`Interactive choices: ${ibits.join(", ")}`); } - - await atomicWriteJson(outputPath, merged); } diff --git a/src/lib/skill-prereqs.ts b/src/lib/skill-prereqs.ts index 905f0c8..68aacd3 100644 --- a/src/lib/skill-prereqs.ts +++ b/src/lib/skill-prereqs.ts @@ -14,7 +14,7 @@ import { homedir } from "node:os"; import { join } from "node:path"; import { type SkillFrontmatter, SkillFrontmatter as SkillSchema } from "../schemas/skill.ts"; import { parseFrontmatter } from "./frontmatter.ts"; -import { hasCommand } from "./platform.ts"; +import { CLAUDE_DIR, hasCommand } from "./platform.ts"; export interface MissingPrereq { kind: "command" | "mcp"; @@ -54,7 +54,7 @@ export async function readAllSkillFrontmatters( * a Set of server names. */ export async function readConfiguredMcpServers(claudeDir?: string): Promise> { - const dir = claudeDir ?? join(homedir(), ".claude"); + const dir = claudeDir ?? CLAUDE_DIR; const sources = [join(dir, "settings.json"), join(homedir(), ".claude.json")]; const names = new Set(); for (const path of sources) { diff --git a/src/scripts/checkpoint.ts b/src/scripts/checkpoint.ts index 9863b66..af3fd44 100644 --- a/src/scripts/checkpoint.ts +++ b/src/scripts/checkpoint.ts @@ -9,7 +9,6 @@ import { lstatSync, readFileSync } from "node:fs"; import { mkdir, unlink, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; import { z } from "zod"; import { @@ -21,6 +20,7 @@ import { } from "../lib/artifact-store.ts"; import { palette } from "../lib/colors.ts"; import { runGit, runProcessFull } from "../lib/git.ts"; +import { claudePath, isoNow } from "../lib/platform.ts"; async function getProjectName(): Promise { const out = await runGit(["rev-parse", "--show-toplevel"]); @@ -67,7 +67,7 @@ const RESOLVE_SPEC = { async function cmdSave(description = "Checkpoint"): Promise { const project = await getProjectName(); - const checkpointDir = join(homedir(), ".claude", "checkpoints", project); + const checkpointDir = claudePath("checkpoints", project); await ensureDir(checkpointDir); const id = timestampId("chk-", "-"); const file = join(checkpointDir, `${id}.json`); @@ -79,7 +79,7 @@ async function cmdSave(description = "Checkpoint"): Promise { ]); const chk: Checkpoint = { id, - timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + timestamp: isoNow(), project, description, git: { @@ -100,7 +100,7 @@ async function cmdSave(description = "Checkpoint"): Promise { async function cmdList(): Promise { const project = await getProjectName(); - const checkpointDir = join(homedir(), ".claude", "checkpoints", project); + const checkpointDir = claudePath("checkpoints", project); await ensureDir(checkpointDir); const entries = await listArtifacts(checkpointDir, /\.json$/); if (entries.length === 0) { @@ -123,7 +123,7 @@ async function cmdList(): Promise { async function resolveTarget(target: string): Promise { const project = await getProjectName(); - const checkpointDir = join(homedir(), ".claude", "checkpoints", project); + const checkpointDir = claudePath("checkpoints", project); return resolveArtifact(checkpointDir, target, RESOLVE_SPEC); } @@ -187,7 +187,7 @@ async function cmdRestore(target: string): Promise { async function cmdClean(keepStr: string): Promise { const keep = Number.parseInt(keepStr, 10) || 10; const project = await getProjectName(); - const checkpointDir = join(homedir(), ".claude", "checkpoints", project); + const checkpointDir = claudePath("checkpoints", project); await ensureDir(checkpointDir); const names = await listArtifacts(checkpointDir, /\.json$/); let entries: Array<{ file: string; mtime: number }>; diff --git a/src/scripts/claude-audit.ts b/src/scripts/claude-audit.ts index a0d5a80..7c93773 100644 --- a/src/scripts/claude-audit.ts +++ b/src/scripts/claude-audit.ts @@ -6,11 +6,10 @@ // Invoked as a one-shot CLI via `bun run claude-audit`; no hook-protocol concerns. import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; -import { ymd } from "../lib/platform.ts"; +import { claudePath, ymd } from "../lib/platform.ts"; -const LOG_DIR = join(homedir(), ".claude", "logs"); +const LOG_DIR = claudePath("logs"); // --- Helpers -------------------------------------------------------------- diff --git a/src/scripts/handoff.ts b/src/scripts/handoff.ts index 27e2ec5..979120f 100644 --- a/src/scripts/handoff.ts +++ b/src/scripts/handoff.ts @@ -9,7 +9,6 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, stat, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; import { listArtifacts, @@ -19,8 +18,9 @@ import { timestampId, } from "../lib/artifact-store.ts"; import { runGit } from "../lib/git.ts"; +import { claudePath, isoNow } from "../lib/platform.ts"; -const HANDOFF_DIR = join(homedir(), ".claude", "handoffs"); +const HANDOFF_DIR = claudePath("handoffs"); const RESOLVE_SPEC = { latestLink: "latest.md", @@ -73,7 +73,7 @@ async function cmdCreate(args: string[]): Promise { const pendingChanges = gitStatus ? gitStatus.split("\n").filter(Boolean).length : 0; const json = { - timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"), + timestamp: isoNow(), project: { name: projectName, path: projectDir }, git: { branch: gitBranch, pendingChanges }, context: { summary, activeTodos: [], keyFiles: [], currentTask: "" }, diff --git a/src/scripts/log-bash.ts b/src/scripts/log-bash.ts index ae8be9f..9578e12 100644 --- a/src/scripts/log-bash.ts +++ b/src/scripts/log-bash.ts @@ -6,18 +6,13 @@ // logs older than CLAUDE_LOG_RETENTION_DAYS (default 1). import { appendFile, mkdir, readdir, stat, unlink } from "node:fs/promises"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; +import { basename } from "node:path"; import { readHookInput } from "../lib/hook-runtime.ts"; -import { pad, ymd } from "../lib/platform.ts"; +import { claudePath, localDatetime, ymd } from "../lib/platform.ts"; -const LOG_DIR = join(homedir(), ".claude", "logs"); +const LOG_DIR = claudePath("logs"); const RETENTION = Number.parseInt(process.env.CLAUDE_LOG_RETENTION_DAYS ?? "1", 10) || 1; -function hms(d: Date): string { - return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} - async function pruneOldLogs(): Promise { // Bash used `find ... -mtime +$RETENTION -delete`. Mirror that: delete any // bash-*.log whose mtime is older than RETENTION days from now. @@ -32,7 +27,7 @@ async function pruneOldLogs(): Promise { entries .filter((f) => /^bash-.*\.log$/.test(f)) .map(async (f) => { - const p = join(LOG_DIR, f); + const p = claudePath("logs", f); try { const st = await stat(p); if (st.mtimeMs < cutoff) await unlink(p); @@ -54,6 +49,8 @@ if (!command) process.exit(0); const now = new Date(); const project = basename(process.cwd()); -const line = `[${hms(now)}] [${project}] ${command}\n`; -const target = join(LOG_DIR, `bash-${ymd(now)}.log`); +// localDatetime gives "YYYY-MM-DD HH:MM:SS"; we only want the HH:MM:SS portion. +const hms = localDatetime(now).slice(11); +const line = `[${hms}] [${project}] ${command}\n`; +const target = claudePath("logs", `bash-${ymd(now)}.log`); await appendFile(target, line).catch(() => {}); diff --git a/src/scripts/post-compact.ts b/src/scripts/post-compact.ts index 3621a06..c75ec12 100644 --- a/src/scripts/post-compact.ts +++ b/src/scripts/post-compact.ts @@ -3,8 +3,8 @@ // Port of scripts/post-compact.sh. Pure stdout; no state writes. import { readdir, stat } from "node:fs/promises"; -import { homedir } from "node:os"; import { join } from "node:path"; +import { claudePath } from "../lib/platform.ts"; console.log("[PostCompact] Context was compacted. Recovery steps:"); console.log(" 1. Re-read your active task plan (check plans/ directory)"); @@ -12,7 +12,7 @@ console.log(" 2. Re-read any files you were actively editing"); console.log(" 3. Check TaskList for current progress"); console.log(" 4. Resume from where you left off"); -const handoffDir = join(homedir(), ".claude", "handoffs"); +const handoffDir = claudePath("handoffs"); try { const entries = await readdir(handoffDir); // Skip symlink aliases (latest.md etc.) so we return the canonical handoff diff --git a/src/scripts/post-failure.ts b/src/scripts/post-failure.ts index b7b3b2b..017d208 100644 --- a/src/scripts/post-failure.ts +++ b/src/scripts/post-failure.ts @@ -5,22 +5,19 @@ // Fail-open: always exits 0. Reads TOOL_NAME / TOOL_ERROR from env. // Per-session failure tally lives at ~/.claude/tmp/tool-failure-counts. -import { appendFile, mkdir, readFile } from "node:fs/promises"; -import { homedir } from "node:os"; +import { appendFile, mkdir } from "node:fs/promises"; import { join } from "node:path"; +import { readState, writeState } from "../lib/hook-runtime.ts"; +import { claudePath, isoNow } from "../lib/platform.ts"; -const CLAUDE_DIR = join(homedir(), ".claude"); -const LOG_DIR = join(CLAUDE_DIR, "logs"); +const LOG_DIR = claudePath("logs"); const LOG_FILE = join(LOG_DIR, "tool-failures.log"); -const TMP_DIR = join(CLAUDE_DIR, "tmp"); -const SESSION_FILE = join(TMP_DIR, "tool-failure-counts"); await mkdir(LOG_DIR, { recursive: true }).catch(() => {}); -await mkdir(TMP_DIR, { recursive: true }).catch(() => {}); const toolName = process.env.TOOL_NAME ?? "unknown"; let toolError = process.env.TOOL_ERROR ?? ""; -const timestamp = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +const timestamp = isoNow(); if (toolError.length > 200) toolError = `${toolError.slice(0, 200)}...`; @@ -33,18 +30,12 @@ const logLine = `${JSON.stringify({ })}\n`; await appendFile(LOG_FILE, logLine).catch(() => {}); -// Per-session tally: session file has one line per failure, tool name only. -let currentCount = 0; -try { - const contents = await readFile(SESSION_FILE, "utf8"); - for (const line of contents.split(/\r?\n/)) { - if (line === toolName) currentCount++; - } -} catch { - // No session file yet — first failure this session. -} - -await appendFile(SESSION_FILE, `${toolName}\n`).catch(() => {}); +// Per-session tally: counts keyed by tool name. +const STATE_FILE = "tool-failure-counts"; +const counts = await readState>(STATE_FILE, {}); +const currentCount = counts[toolName] ?? 0; +counts[toolName] = currentCount + 1; +await writeState(STATE_FILE, counts).catch(() => {}); const newCount = currentCount + 1; if (newCount >= 3) { diff --git a/src/scripts/project-init.ts b/src/scripts/project-init.ts index 172e120..a7897ca 100644 --- a/src/scripts/project-init.ts +++ b/src/scripts/project-init.ts @@ -6,13 +6,12 @@ import { existsSync, readFileSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, join, resolve } from "node:path"; import { info, success, warn } from "../lib/colors.ts"; +import { claudePath, isoNow } from "../lib/platform.ts"; -const CLAUDE_DIR = join(homedir(), ".claude"); -const SOURCE_AGENTS = join(CLAUDE_DIR, "AGENTS.md"); -const VERSION_FILE = join(CLAUDE_DIR, ".cc-settings-version"); +const SOURCE_AGENTS = claudePath("AGENTS.md"); +const VERSION_FILE = claudePath(".cc-settings-version"); function installedVersion(): string { if (!existsSync(VERSION_FILE)) return "unknown"; @@ -47,7 +46,7 @@ function isManaged(file: string): boolean { function stampAgents(): string { const version = installedVersion(); - const ts = new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); + const ts = isoNow(); const body = readFileSync(SOURCE_AGENTS, "utf8"); return `\n${body}`; } diff --git a/src/scripts/prune-mcp-auth-cache.ts b/src/scripts/prune-mcp-auth-cache.ts index 8e79515..b30c962 100644 --- a/src/scripts/prune-mcp-auth-cache.ts +++ b/src/scripts/prune-mcp-auth-cache.ts @@ -22,12 +22,10 @@ // MCP_NEEDS_AUTH_CACHE cache file path (default ~/.claude/mcp-needs-auth-cache.json) import { readFile, unlink, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { claudePath } from "../lib/platform.ts"; const DEFAULT_TTL_MS = 60 * 60 * 1000; -const CACHE_PATH = - process.env.MCP_NEEDS_AUTH_CACHE ?? join(homedir(), ".claude", "mcp-needs-auth-cache.json"); +const CACHE_PATH = process.env.MCP_NEEDS_AUTH_CACHE ?? claudePath("mcp-needs-auth-cache.json"); const TTL_MS = Number.parseInt(process.env.MCP_NEEDS_AUTH_TTL_MS ?? "", 10) || DEFAULT_TTL_MS; type CacheEntry = { timestamp?: number }; diff --git a/src/scripts/review-batch.ts b/src/scripts/review-batch.ts index df3418e..411a33e 100644 --- a/src/scripts/review-batch.ts +++ b/src/scripts/review-batch.ts @@ -6,10 +6,9 @@ // (from ~/.claude/swarm.log). The /review-batch skill turns this into per-change // re-entry cards. Fail-soft: missing git/log just yields empty sections. -import { homedir } from "node:os"; -import { join } from "node:path"; import { runGit } from "../lib/git.ts"; import { readState } from "../lib/hook-runtime.ts"; +import { claudePath } from "../lib/platform.ts"; import { ageMs, formatAge, type ReviewQueueState } from "../lib/review-queue.ts"; const rq = await readState("review-queue.json", { awaiting: 0 }); @@ -20,7 +19,7 @@ const [unstaged, staged] = await Promise.all([ let swarmTail = ""; try { - const log = await Bun.file(join(homedir(), ".claude", "swarm.log")).text(); + const log = await Bun.file(claudePath("swarm.log")).text(); swarmTail = log.trimEnd().split("\n").slice(-12).join("\n"); } catch { // no swarm log yet — fine diff --git a/src/scripts/session-start.ts b/src/scripts/session-start.ts index beb3f1d..b421353 100644 --- a/src/scripts/session-start.ts +++ b/src/scripts/session-start.ts @@ -17,18 +17,16 @@ import { unlink, writeFile, } from "node:fs/promises"; -import { homedir } from "node:os"; import { basename, join } from "node:path"; import { runGit } from "../lib/git.ts"; import { getClaudeMdMonitor } from "../lib/hook-config.ts"; import { readState, writeState } from "../lib/hook-runtime.ts"; -import { hasCommand, pad, ymd } from "../lib/platform.ts"; +import { CLAUDE_DIR, hasCommand, localDatetime } from "../lib/platform.ts"; import { projectAwareness } from "../lib/project-awareness.ts"; import { onHeadObserved, type ReviewQueueState } from "../lib/review-queue.ts"; import { teamKnowledgeAwareness } from "../lib/team-knowledge.ts"; import { computeDrift, readPackagedVersion, readSentinelInfo } from "../lib/version-delta.ts"; -const CLAUDE_DIR = join(homedir(), ".claude"); const PROJECT_DIR = process.cwd(); const PROJECT_NAME = basename(PROJECT_DIR); @@ -154,7 +152,7 @@ async function autoWarmTldr(): Promise { void (async () => { try { await proc.exited; - const ts = formatDate(new Date()); + const ts = localDatetime(); await appendFile( join(CLAUDE_DIR, "sessions.log"), `${ts} - TLDR warmed: ${PROJECT_NAME}\n`, @@ -166,10 +164,6 @@ async function autoWarmTldr(): Promise { })(); } -function formatDate(d: Date): string { - return `${ymd(d)} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} - // --- Phase 1: background tasks -------------------------------------------- // Clean per-session temp files from the previous session. @@ -214,7 +208,7 @@ const sessionTitlePrune = pruneSessionTitles(join(CLAUDE_DIR, "session-titles"), await logRotations[0]; await appendFile( join(CLAUDE_DIR, "sessions.log"), - `${formatDate(new Date())} - Session started in ${PROJECT_DIR}\n`, + `${localDatetime()} - Session started in ${PROJECT_DIR}\n`, ).catch(() => {}); // --- Phase 3: fire-and-forget TLDR warming ------------------------------- diff --git a/src/scripts/session-title.ts b/src/scripts/session-title.ts index 3cd630f..25a488c 100644 --- a/src/scripts/session-title.ts +++ b/src/scripts/session-title.ts @@ -8,9 +8,9 @@ import { existsSync } from "node:fs"; import { mkdir, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { readHookInput } from "../lib/hook-runtime.ts"; +import { claudePath } from "../lib/platform.ts"; const STOPWORDS = new Set([ "the", @@ -56,7 +56,7 @@ async function main(): Promise { if (!SESSION_ID || !PROMPT) return; - const STATE_DIR = join(homedir(), ".claude", "session-titles"); + const STATE_DIR = claudePath("session-titles"); const STATE_FILE = join(STATE_DIR, `${SESSION_ID}.flag`); // Already titled this session — skip. diff --git a/src/scripts/stop-failure.ts b/src/scripts/stop-failure.ts index 8661a9a..9c1ac37 100644 --- a/src/scripts/stop-failure.ts +++ b/src/scripts/stop-failure.ts @@ -5,12 +5,10 @@ // Reads hook JSON from stdin, logs the failure, and surfaces to the user. import { appendFile } from "node:fs/promises"; -import { homedir } from "node:os"; -import { join } from "node:path"; import { readHookInput } from "../lib/hook-runtime.ts"; -import { pad } from "../lib/platform.ts"; +import { claudePath, localDatetime } from "../lib/platform.ts"; -const LOG_FILE = join(homedir(), ".claude", "api-failures.log"); +const LOG_FILE = claudePath("api-failures.log"); type StopFailureInput = { error?: { @@ -19,16 +17,11 @@ type StopFailureInput = { }; }; -function formatTimestamp(d: Date): string { - // Bash parity: `date '+%Y-%m-%d %H:%M:%S'` — local time, space separator. - return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; -} - const input = await readHookInput(); const errorType = input.error?.type ?? "unknown"; const errorMessage = input.error?.message ?? "Unknown error"; -const timestamp = formatTimestamp(new Date()); +const timestamp = localDatetime(); await appendFile(LOG_FILE, `[${timestamp}] type=${errorType} msg=${errorMessage}\n`).catch(() => { // Never fail the hook on log write. diff --git a/src/scripts/swarm-log.ts b/src/scripts/swarm-log.ts index 0547d3f..6fcb384 100644 --- a/src/scripts/swarm-log.ts +++ b/src/scripts/swarm-log.ts @@ -9,11 +9,11 @@ // TASK_SUBJECT — populated by CC for task events import { appendFile, mkdir } from "node:fs/promises"; -import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname } from "node:path"; +import { claudePath } from "../lib/platform.ts"; const event = process.argv[2] ?? ""; -const logPath = join(homedir(), ".claude", "swarm.log"); +const logPath = claudePath("swarm.log"); const agentType = process.env.AGENT_TYPE ?? "?"; const agentId = process.env.AGENT_ID ?? "?"; diff --git a/src/setup.ts b/src/setup.ts index 42d56e3..9599d37 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -21,24 +21,15 @@ import { cp, mkdir, readdir, rm } from "node:fs/promises"; import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { checkCliTools, printPreflightReport } from "./lib/cli-preflight.ts"; -import { - boxEnd, - boxLine, - boxStart, - debug, - error, - info, - palette, - showBanner, - success, - warn, -} from "./lib/colors.ts"; +import { debug, error, info, palette, showBanner, success, warn } from "./lib/colors.ts"; import { composeSettings } from "./lib/compose-settings.ts"; import { formatFrontmatterIssues, validateFrontmatters } from "./lib/frontmatter-validate.ts"; import { writeFingerprint as writeHooksFingerprint, writeSrcManifest, } from "./lib/hooks-fingerprint.ts"; +import { cmdRollback, printHelp } from "./lib/install-cmds.ts"; +import { cmdDryRun, printStatus, showSummary } from "./lib/install-display.ts"; import { atomicWriteJson, JsonParseError, readJsonOrNull } from "./lib/json-io.ts"; import { applyLightProfile, @@ -49,22 +40,20 @@ import { } from "./lib/light-profile.ts"; import { MANAGED_SKILLS } from "./lib/managed-skills.ts"; import { - CLAUDE_JSON_PATH, installMcpToClaudeJson, type McpServers, mergeSettingsWithMcpPreservation, removeManagedMcpServers, } from "./lib/mcp.ts"; import { ensurePythonPackage, ensureSystemPackage, getInstallHint } from "./lib/packages.ts"; -import { getTimestamp, hasCommand, isWindows } from "./lib/platform.ts"; +import { CLAUDE_DIR, getTimestamp, hasCommand, isWindows } from "./lib/platform.ts"; +import { printMergeAccounting } from "./lib/settings-merge.ts"; import { formatPrereqWarnings, reportMissingPrereqs } from "./lib/skill-prereqs.ts"; import { gatherStatus } from "./lib/status.ts"; -import type { StatusData } from "./lib/status-types.ts"; import { buildVersionDelta, readInstalledVersion } from "./lib/version-delta.ts"; import { Settings } from "./schemas/settings.ts"; const VERSION = "11.27.1"; // installer merger deep-merges object defaults — nested config keys (e.g. attribution.sessionUrl) now land into existing user blocks; Fable 5 still suspended — decision tier on opus[1m] -const CLAUDE_DIR = join(homedir(), ".claude"); // --- Arg parsing --------------------------------------------------------- @@ -105,76 +94,6 @@ export function parseArgs(argv: string[]): Args { return args; } -function printHelp(): void { - console.log(`cc-settings installer v${VERSION} - -Usage: bun src/setup.ts [flags] - -Flags: - --source= Source repo path (default: parent of setup.ts). - --rollback[=TS] Restore newest backup, or one matching timestamp TS. - --dry-run Print planned actions; do not touch disk. - --light Install raw Claude Code + statusLine + share-learning only: - • skills: share-learning (only) - • settings.json: $schema + statusLine only - • no MCP servers, no hooks, no effort override - • no CLAUDE.md, AGENTS.md, agents, rules, profiles, - docs, or permission rules - Re-run without --light to upgrade to full. - --status Report installed version, drift vs repo HEAD, missing - managed skills, hooks, key env vars, and MCP servers. - --interactive Prompt on settings.json conflicts (scalar overrides, team - additions to allow/ask rules, new hook groups). Also opt in - via CC_INTERACTIVE=1. - --migrate-only Run only the settings.json merger + version sentinel; - skip file copy, dependency install, and skill/agent - refresh. Use after a cc-settings update if you only - want the merger's deprecation prune to apply. - --help, -h Show this message. - -Rollback examples: - bun src/setup.ts --rollback - bun src/setup.ts --rollback=2026-04-20T10-00-00Z`); -} - -// --- Rollback ------------------------------------------------------------ - -async function cmdRollback(target: string | true): Promise { - const backupDir = join(CLAUDE_DIR, "backups"); - if (!existsSync(backupDir)) { - error(`No backups directory found at ${backupDir}`); - return 1; - } - const entries = (await readdir(backupDir)) - .filter((e) => /^backup-.*\.tar\.gz$/.test(e)) - .sort() - .reverse(); - const match = target === true ? entries[0] : entries.find((e) => e.includes(target)); - if (!match) { - error("No matching backup found."); - console.error("Available backups:"); - for (const e of entries.slice(0, 5)) console.error(` ${e}`); - return 1; - } - info(`Rolling back from: ${match}`); - const archivePath = join(backupDir, match); - // Newer archives are $HOME-relative (entries prefixed with ".claude/", plus a - // top-level ".claude.json"); pre-MCP-backup archives are ~/.claude-relative - // (bare "settings.json"). Detect the layout so each restores to the right place. - const listing = Bun.spawn(["tar", "-tzf", archivePath], { stdout: "pipe", stderr: "ignore" }); - const archiveEntries = (await new Response(listing.stdout).text()).trim().split("\n"); - await listing.exited; - const homeRelative = archiveEntries.some((e) => e.startsWith(".claude/") || e === ".claude.json"); - const proc = Bun.spawn(["tar", "-xzf", archivePath], { - cwd: homeRelative ? homedir() : CLAUDE_DIR, - stdout: "inherit", - stderr: "inherit", - }); - const code = await proc.exited; - if (code === 0) success("Restored. Restart Claude Code to apply."); - return code; -} - // --- Install plan -------------------------------------------------------- /** @@ -196,9 +115,10 @@ export interface InstallStep { /** * Produce the complete planned footprint for an install: every file/dir that * will be copied or pruned, derived from PROFILE_MANIFEST and LIGHT_SKILLS. - * All four traversal sites (installConfigFiles, removeLightIncompatibleFiles, - * showSummary, cmdDryRun) derive from this single list instead of re-walking - * the manifest independently. + * The display sites (showSummary, cmdDryRun) and the light-profile install path + * (installConfigFiles) consume this list instead of re-walking the manifest; + * the full-profile install reads PROFILE_MANIFEST directly (the plan is derived + * from the same manifest). * * Does NOT touch disk — pure computation from the manifest + existsSync checks. */ @@ -431,20 +351,21 @@ async function copyDirContents(srcDir: string, dstDir: string): Promise { /** * Execute the copy/prune steps from buildInstallPlan. cleanOldConfig must * already have run so MANAGED_SKILLS dirs are wiped before copy. + * + * For the light profile, all plan steps (copy + prune) are executed directly + * from buildInstallPlan — making buildInstallPlan the honest single source of + * truth for what the light path touches (§1.3). */ async function installConfigFiles(source: string, profile: Profile): Promise { - const plan = buildInstallPlan(source, profile); - if (profile === "light") { - // Copy LIGHT_SKILLS subset. + const plan = buildInstallPlan(source, profile); + // Execute copy steps: LIGHT_SKILLS subset via filtered copy. const lightSkillSet = new Set(LIGHT_SKILLS); await copyDirContentsFiltered(join(source, "skills"), join(CLAUDE_DIR, "skills"), (name) => lightSkillSet.has(name), ); - // Prune skill dirs from the plan that cleanOldConfig may have left behind - // (MANAGED_SKILLS sweep in cleanOldConfig covers the known list; skill-prune - // steps from the plan cover any that slipped through). - const pruneSteps = plan.filter((s) => s.action === "prune" && s.rel.startsWith("skills/")); + // Execute all prune steps from the plan (skill dirs + full-only rootFiles/dirs). + const pruneSteps = plan.filter((s) => s.action === "prune"); await Promise.all( pruneSteps.map((s) => rm(join(CLAUDE_DIR, s.rel), { recursive: true, force: true }).catch(() => {}), @@ -463,25 +384,6 @@ async function installConfigFiles(source: string, profile: Profile): Promise { - // buildInstallPlan for light already includes prune steps for full-only - // rootFiles and dirs. Re-use the plan here so there's one source of truth. - // We use an empty sourceDir because prune steps don't read the source. - const plan = buildInstallPlan("", "light"); - const pruneNonSkill = plan.filter((s) => s.action === "prune" && !s.rel.startsWith("skills/")); - const removeIfExists = (p: string) => rm(p, { recursive: true, force: true }).catch(() => {}); - await Promise.all([ - ...pruneNonSkill.map((s) => removeIfExists(join(CLAUDE_DIR, s.rel))), - removeIfExists(join(CLAUDE_DIR, "contexts")), // legacy; contexts/ retired - ]); -} - async function installTsSources(source: string): Promise { const srcTs = join(source, "src"); if (!existsSync(srcTs)) return; @@ -558,9 +460,13 @@ async function installSettings( // MCP block was validated exactly once — by composeSettings, whose Settings // schema types mcpServers with the McpServers schema. const teamMcp = (fullComposed.mcpServers ?? {}) as McpServers; - await mergeSettingsWithMcpPreservation(userSettingsPath, fullComposed, userSettingsPath, { - interactive, - }); + const accounting = await mergeSettingsWithMcpPreservation( + userSettingsPath, + fullComposed, + userSettingsPath, + { interactive }, + ); + if (accounting) printMergeAccounting(accounting, { interactive }); await installMcpToClaudeJson(teamMcp); // Record a SHA256 of the merged hooks block so verify-hooks.ts (the @@ -633,222 +539,8 @@ async function writeVersionSentinel(sourceDir: string, profile: Profile): Promis await atomicWriteJson(join(CLAUDE_DIR, ".cc-settings-version"), payload); } -// --- Summary ------------------------------------------------------------- - -async function countEntries(dir: string, pattern: RegExp): Promise { - const full = join(CLAUDE_DIR, dir); - if (!existsSync(full)) return 0; - try { - const entries = await readdir(full); - return entries.filter((e) => pattern.test(e)).length; - } catch { - return 0; - } -} - -async function showSummary(profile: Profile): Promise { - const profileLabel = profile === "light" ? " [light]" : ""; - console.log(""); - boxStart(`Installed${profileLabel}`); - if (profile === "light") { - boxLine("ok", "settings.json ($schema + statusLine only)"); - for (const skill of LIGHT_SKILLS) boxLine("ok", `skills/${skill}`); - boxLine("ok", "src/ (TS; statusLine + libs)"); - boxLine("ok", "memory/"); - } else { - // Rendered from PROFILE_MANIFEST so the summary can't drift from what - // installConfigFiles actually copies. Dirs with installed .md files show - // a count; container dirs (skills/) just list. - const ROOT_FILE_LABELS: Record = { - "CLAUDE.md": "(Claude-Code config)", - "AGENTS.md": "(portable standards)", - }; - const manifest = PROFILE_MANIFEST.full; - for (const [, dest] of manifest.rootFiles) { - const label = ROOT_FILE_LABELS[dest]; - boxLine("ok", label ? `${dest} ${label}` : dest); - } - boxLine("ok", "settings.json (TS hooks)"); - boxLine("ok", "~/.claude.json (MCP servers)"); - const counts = await Promise.all(manifest.dirs.map((d) => countEntries(d, /\.md$/))); - manifest.dirs.forEach((d, i) => { - const n = counts[i] ?? 0; - boxLine("ok", n > 0 ? `${d}/ (${n})` : `${d}/`); - }); - boxLine("ok", "src/ (TS; hooks + scripts + libs + schemas)"); - boxLine("ok", "memory/"); - } - boxEnd(); - - if (profile === "light") { - console.log(""); - console.log( - `${palette.dim}Light profile: raw Claude Code · statusLine · share-learning skill only${palette.reset}`, - ); - console.log( - `${palette.dim}No CLAUDE.md, AGENTS.md, MCP servers, hooks, or effort override.${palette.reset}`, - ); - console.log(`${palette.dim}Re-run without --light to upgrade to full.${palette.reset}`); - } - - const claudeJson = (await readJsonOrNull(CLAUDE_JSON_PATH)) as { - mcpServers?: Record; - } | null; - const servers = Object.entries(claudeJson?.mcpServers ?? {}); - if (servers.length > 0) { - console.log(""); - console.log(`${palette.bold}MCP servers in ~/.claude.json:${palette.reset}`); - // Group by `_status` annotation. Servers without a status are listed as - // "user-added" — they came from the user's machine, not the team config. - const core: string[] = []; - const optional: string[] = []; - const userAdded: string[] = []; - for (const [name, server] of servers) { - const status = (server as { _status?: unknown })._status; - if (status === "core") core.push(name); - else if (status === "optional") optional.push(name); - else userAdded.push(name); - } - if (core.length > 0) { - console.log(` ${palette.dim}core:${palette.reset}`); - for (const s of core) console.log(` - ${s}`); - } - if (optional.length > 0) { - console.log(` ${palette.dim}optional (manually added):${palette.reset}`); - for (const s of optional) console.log(` - ${s}`); - } - if (userAdded.length > 0) { - console.log(` ${palette.dim}user-added:${palette.reset}`); - for (const s of userAdded) console.log(` - ${s}`); - } - } -} - -// --- Dry run ------------------------------------------------------------- - -async function cmdDryRun(source: string, profile: Profile): Promise { - const profileLabel = profile === "light" ? " [light profile]" : ""; - console.log(`cc-settings installer v${VERSION} — dry-run${profileLabel}`); - console.log(`source: ${source}`); - console.log(`target: ${CLAUDE_DIR}`); - console.log(""); - - if (profile === "light") { - console.log("Would install (light = raw Claude Code + statusLine + share-learning):"); - const items: Array<[string, string]> = [ - ...LIGHT_SKILLS.map((s): [string, string] => [`skills/${s}/`, `→ ~/.claude/skills/${s}/`]), - ["src/", "→ ~/.claude/src/ (all TS)"], - ["config/", "→ ~/.claude/settings.json ($schema + statusLine only)"], - ]; - for (const [rel, effect] of items) { - const mark = existsSync(join(source, rel)) ? "✓" : " "; - console.log(` ${mark} ${rel.padEnd(28)} ${effect}`); - } - console.log(""); - console.log("Light profile: no CLAUDE.md · no AGENTS.md · no MCP servers · no hooks"); - console.log(" no agents · no rules · no profiles · no docs"); - console.log(" default Claude Code permissions · default effort"); - } else { - console.log("Would install:"); - // Rendered from PROFILE_MANIFEST so the dry-run table can't drift from - // what installConfigFiles actually copies. - const items: Array<[string, string]> = [ - ...PROFILE_MANIFEST.full.rootFiles.map(([src, dest]): [string, string] => [ - src, - `→ ~/.claude/${dest}`, - ]), - ["config/", "→ ~/.claude/settings.json (composed + MCP-merged)"], - ["src/", "→ ~/.claude/src/ (all TS)"], - ...PROFILE_MANIFEST.full.dirs.map((d): [string, string] => [`${d}/`, `→ ~/.claude/${d}/`]), - ]; - for (const [rel, effect] of items) { - const mark = existsSync(join(source, rel)) ? "✓" : " "; - console.log(` ${mark} ${rel.padEnd(22)} ${effect}`); - } - } - - console.log(""); - console.log("No files written. Re-run without --dry-run to install."); -} - // --- Status -------------------------------------------------------------- -function printStatus(data: StatusData): void { - console.log("cc-settings --status"); - console.log(""); - - // Installed version - if (data.sentinel.version) { - const profileLabel = data.sentinel.profile ? ` [${data.sentinel.profile}]` : ""; - console.log( - ` installed: v${data.sentinel.version}${profileLabel} (${data.sentinel.installedAt ?? "unknown"})`, - ); - } else { - console.log( - ` installed: ${palette.yellow}none${palette.reset} (no sentinel at ~/.claude/.cc-settings-version)`, - ); - } - console.log(` packaged: v${data.packagedVersion}`); - - // Git drift - if (data.git?.sha) { - const g = data.git; - const driftNote = - g.behind === null - ? "(sentinel absent — can't compute drift)" - : g.behind === 0 - ? `${palette.green}up to date${palette.reset}` - : `${palette.yellow}${g.behind} commit(s) since install${palette.reset}`; - console.log(` repo HEAD: ${g.sha} ${driftNote}`); - } - - console.log(""); - console.log("Managed skills:"); - console.log(` present: ${data.skills.presentCount}/${data.skills.shippedCount}`); - if (data.skills.missing.length > 0) { - console.log(` missing: ${data.skills.missing.join(", ")}`); - } - - console.log(""); - console.log("Hooks:"); - console.log( - ` events registered: ${data.hooks.events.length} (${data.hooks.groupCount} group(s) total)`, - ); - if (data.hooks.events.length > 0) { - console.log(` ${data.hooks.events.sort().join(", ")}`); - } - - console.log(""); - console.log("Env vars:"); - for (const { key, value } of data.envVars) { - const mark = - value === undefined - ? `${palette.yellow}✗${palette.reset}` - : `${palette.green}✓${palette.reset}`; - const val = value === undefined ? "(unset)" : value; - console.log(` ${mark} ${key}=${val}`); - } - - console.log(""); - console.log("Permissions:"); - console.log(` allow: ${data.permissions.allowCount} deny: ${data.permissions.denyCount}`); - - console.log(""); - console.log("MCP servers:"); - const { servers } = data.mcp; - console.log( - ` configured: ${servers.length}${servers.length > 0 ? ` (${servers.join(", ")})` : ""}`, - ); - - console.log(""); - - if (data.warnings.length === 0) { - success("all checks passed"); - } else { - for (const { message } of data.warnings) warn(message); - } -} - async function cmdStatus(sourceDir: string): Promise { const data = await gatherStatus(sourceDir, CLAUDE_DIR, VERSION); printStatus(data); @@ -857,16 +549,6 @@ async function cmdStatus(sourceDir: string): Promise { // --- Main ---------------------------------------------------------------- -/** - * Run the migrate-only path: backup + settings merger + version sentinel. - * Skips file copy, dependency install, and skill/agent refresh. - */ -async function runMigrateOnly(args: Args): Promise { - info("Migrate-only: backup + merger + sentinel; skipping file copy"); - await createBackup(); - await createDirectories(); // idempotent — ensures ~/.claude/ shape exists for merger -} - /** * Run the full install path: deps → backup → dirs → clean → light-incompatible * removal → file copy → TS source copy → src manifest. @@ -886,14 +568,10 @@ async function runFullInstall(args: Args): Promise { info("Installing configuration..."); await createDirectories(); await cleanOldConfig(); - // For light: remove dirs that full installs but light must not have - // (CLAUDE.md, AGENTS.md, agents/, rules/, profiles/, docs/). - // Must run AFTER cleanOldConfig so any leftover full-install content is gone. - if (args.profile === "light") { - await removeLightIncompatibleFiles(); - } // Disjoint destination trees (config dirs vs ~/.claude/src), so install both - // in parallel. Both must follow the clean above. + // in parallel. Both must follow the clean above. For light, installConfigFiles + // owns the full footprint: it copies the LIGHT_SKILLS subset and prunes every + // full-only target (CLAUDE.md, AGENTS.md, agents/, rules/, profiles/, docs/). await Promise.all([ installConfigFiles(args.sourceDir, args.profile), installTsSources(args.sourceDir), @@ -909,7 +587,7 @@ async function runFullInstall(args: Args): Promise { async function main(): Promise { const args = parseArgs(process.argv.slice(2)); if (args.help) { - printHelp(); + printHelp(VERSION); return 0; } if (args.status) { @@ -919,7 +597,7 @@ async function main(): Promise { return await cmdRollback(args.rollback); } if (args.dryRun) { - await cmdDryRun(args.sourceDir, args.profile); + await cmdDryRun(args.sourceDir, args.profile, VERSION); return 0; } @@ -942,7 +620,9 @@ async function main(): Promise { // Dispatch to migrate-only or full install path. if (args.migrateOnly) { - await runMigrateOnly(args); + info("Migrate-only: backup + merger + sentinel; skipping file copy"); + await createBackup(); + await createDirectories(); // idempotent — ensures ~/.claude/ shape exists for merger } else { await runFullInstall(args); } diff --git a/tests/mcp.test.ts b/tests/mcp.test.ts index 8fbc401..9f43637 100644 --- a/tests/mcp.test.ts +++ b/tests/mcp.test.ts @@ -12,15 +12,6 @@ import { mergeSettingsWithMcpPreservation, } from "../src/lib/mcp.ts"; -async function withTmp(fn: (dir: string) => Promise): Promise { - const dir = await mkdtemp(join(tmpdir(), "cc-mcp-schema-")); - try { - await fn(dir); - } finally { - await rm(dir, { recursive: true, force: true }); - } -} - describe("mcp — user-only detection", () => { test("findUserOnlyServers returns names in user but not in team", () => { const only = findUserOnlyServers( diff --git a/tests/settings-merge.test.ts b/tests/settings-merge.test.ts index 1d230ff..7f6bf11 100644 --- a/tests/settings-merge.test.ts +++ b/tests/settings-merge.test.ts @@ -670,7 +670,7 @@ describe("mergeSettingsWithMcpPreservation — safeParse validation", () => { await expect( mergeSettingsWithMcpPreservation(userPath, team, outPath), - ).resolves.toBeUndefined(); + ).resolves.toBeDefined(); const merged = JSON.parse(await Bun.file(outPath).text()); // user model wins @@ -698,7 +698,7 @@ describe("mergeSettingsWithMcpPreservation — safeParse validation", () => { // Must not throw — forward-compat safety await expect( mergeSettingsWithMcpPreservation(userPath, team, outPath), - ).resolves.toBeUndefined(); + ).resolves.toBeDefined(); // The unknown key should be preserved in the output (user wins via // userWinsScalarStrategy fallback) @@ -720,7 +720,7 @@ describe("mergeSettingsWithMcpPreservation — safeParse validation", () => { await expect( mergeSettingsWithMcpPreservation(userPath, team, outPath), - ).resolves.toBeUndefined(); + ).resolves.toBeDefined(); // teamNewFeature should be present (user has no value → team wins) const merged = JSON.parse(await Bun.file(outPath).text()); @@ -737,9 +737,7 @@ describe("mergeSettingsWithMcpPreservation — safeParse validation", () => { const userPath = join(dir, "user.json"); // does not exist const outPath = join(dir, "out.json"); - await expect( - mergeSettingsWithMcpPreservation(userPath, team, outPath), - ).resolves.toBeUndefined(); + await expect(mergeSettingsWithMcpPreservation(userPath, team, outPath)).resolves.toBeNull(); const out = JSON.parse(await Bun.file(outPath).text()); expect(out.model).toBe("claude-opus-4-5");