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
9 changes: 7 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion biome.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
20 changes: 10 additions & 10 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
9 changes: 4 additions & 5 deletions src/hooks/promote-memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);

Expand All @@ -29,7 +28,7 @@ async function readSeenSet(): Promise<string[]> {
}

async function writeSeenSet(seen: string[]): Promise<void> {
await mkdir(join(CLAUDE_DIR, ".cache"), { recursive: true });
await mkdir(claudePath(".cache"), { recursive: true });
await writeFile(SEEN_SET_PATH, JSON.stringify(seen));
}

Expand Down
8 changes: 4 additions & 4 deletions src/hooks/safety-net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ------------------------------------------------------------

Expand All @@ -35,7 +35,7 @@ function redactSecrets(text: string): string {

async function logBlocked(cmd: string, reason: string): Promise<void> {
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,
Expand Down
60 changes: 21 additions & 39 deletions src/hooks/statusline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -77,11 +78,8 @@ async function buildGitStatus(cwd: string): Promise<string | null> {
]);
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) {
Expand All @@ -90,7 +88,7 @@ async function buildGitStatus(cwd: string): Promise<string | null> {
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 {
Expand All @@ -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.
Expand Down Expand Up @@ -148,9 +146,9 @@ async function main(): Promise<void> {
// `+` = 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);
Expand All @@ -166,29 +164,21 @@ async function main(): Promise<void> {

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
// your review (written by tool-cadence.ts). Suppressed at 0 — yellow
// under the threshold, red at/over CC_MAX_UNREVIEWED.
const reviewQueue = await readState<ReviewQueueState>("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
Expand All @@ -199,29 +189,21 @@ async function main(): Promise<void> {
{ 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`);
}
Expand Down
Loading
Loading