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
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,9 +241,11 @@ Running two or more sessions concurrently under one identity — two terminals,

### Resolving a retained superseded farm

Swapping in a resynced farm carries the identity's own real local data (credentials, `identity.json`, daemon/runtime state — anything that isn't a symlink or a directory the previous resync itself materialised) across from the superseded farm into the new one. When a top-level name exists in both, the swap never overwrites in either direction — it leaves the superseded farm on disk and reports it (`FARM_PREVIOUS_RETAINED`, or `FARM_SWAP_RECOVERED` when a crash-recovery pass on a later launch rediscovers it) rather than guessing which copy is more important. This can only happen when the underlying data genuinely differs in a way the tool has no way to judge safely on its own — the category system only tracks whether data is *shared across identities*, not whether it's *precious vs. disposable*.
Swapping in a resynced farm carries the identity's own real local data (credentials, `identity.json`, daemon/runtime state — anything that isn't a symlink or a directory the previous resync itself materialised) across from the superseded farm into the new one. When a top-level name exists in both, the swap does not guess which copy matters more — for most categories the tool has no way to judge that safely: `categories` only tracks whether data is *shared across identities*, not whether it's *precious vs. disposable*, and overwriting either copy could discard something real.

`claude-use identity resolve <name>` walks every retained `.{name}.previous.*` directory for that identity and resolves each collision interactively: keep the current farm's copy, keep the superseded farm's copy, or skip it for now (leaving it exactly as-is for a later run to ask about again). A superseded directory is only removed once every one of its own conflicts has been decided; skipping even one leaves the whole directory retained.
One category is the exception. `runtime`'s own definition (see the [category table](#category-based-sharing) above) is specifically "live per-process or per-machine artifacts" — a daemon lock, an MCP auth-needed cache, an update-check result — that make no sense being fought over at all, let alone asked about. A colliding name whose category resolves to `runtime` is discarded from the superseded copy automatically, with nothing kept from the old side and nothing asked: `keep-new` is not a judgement call for this category, it is what the category already means. This needs only the name's static classification, never the resolved shared/not-shared decision for the current directory — a `runtime` entry is disposable whether or not this identity currently chooses to share it.

For everything else, the swap leaves the superseded farm on disk and reports it (`FARM_PREVIOUS_RETAINED`, or `FARM_SWAP_RECOVERED` when a crash-recovery pass on a later launch rediscovers it, naming what it auto-resolved and what it could not) rather than guessing. `claude-use identity resolve <name>` walks every retained `.{name}.previous.*` directory for that identity, auto-resolving any further `runtime` collisions it finds the same way, and asks about the rest interactively: keep the current farm's copy, keep the superseded farm's copy, or skip it for now (leaving it exactly as-is for a later run to ask about again). A superseded directory is only removed once every one of its own conflicts has been decided; skipping even one leaves the whole directory retained.

## Portable config: `.claude-use.json`

Expand Down Expand Up @@ -624,6 +626,7 @@ the resolver's cascade and materialisation logic is exactly the kind of thing th
- The exact two-phase merge algorithm: a shallow layer's specific entry surviving a later, deeper layer's blanket category flip on the same category; an exact literal key beating a glob from an earlier layer; two globs from different layers resolving to the later layer's value; two globs from the *same* layer resolving by longest-literal-prefix and then source order; two layers setting the identical category resolving to plain last-layer-wins
- Conditional entries with injectable/fake mtimes, a fake resolved branch, and a fake env snapshot (never real filesystem/git/environment state, so tests aren't time-dependent, git-dependent, or slow) — a `newerThan` condition including a fresh file and excluding a stale one under the same glob, a `branch` condition applying only on a matching branch, an `env` condition applying only when the right variable is set, and a conditionally-matched subtree always being materialised rather than symlinked
- A materialised directory reconciling any real (non-symlink) children written since the last resync back into `~/.claude` before re-deciding, and collapsing back into a plain symlink once its split condition no longer holds
- A `runtime`-category collision between a superseded farm and the current one resolving automatically (`carryOver`'s own `classification` parameter, covered directly, and end-to-end through both `resyncFarm`'s crash-recovery path and `resolveFarmConflicts`'s interactive one) — the decision never calling the caller's `decide`/prompt at all, a genuinely ambiguous collision alongside it in the same superseded farm still reaching that prompt, and the whole auto-resolution falling back to the old fully-manual behaviour when no `classification` is given

`identityManager.ts`, `configProfiles.ts`, `directoryRules.ts`, and `configure.ts` stay thin adapters over the resolver, so most of their correctness rides on the resolver's own test coverage above. The one exception is `listIdentities`, whose own tests cover a deliberate departure from the "throw a validation error and let it propagate" convention: an `identity.json` that is present but unreadable — malformed JSON, or valid JSON this version's `IdentitySchema` rejects — is reported as that one identity's own unreadable entry, so a single bad file never hides every *other* identity from `claude-use identity list` at the moment they most need to be visible. Only those two content-shaped failures are absorbed; a permission error still propagates. A wholly *absent* `identity.json` remains a silent skip rather than a problem, and both it and `doctor`'s own enumeration filter out directories whose name starts with `.`, since `IdentitySchema` requires an identity name to start with a letter or digit and a resync's own `.<identity>.scratch.<suffix>`/`.<identity>.previous.<suffix>` directories are therefore never identities to report on. `launcher.ts` carries three separately-testable responsibilities of its own that aren't covered by the resolver's purity, and need their own coverage: translating a resolved `Map<path, boolean>` into real filesystem side effects (creating/removing symlinks, materialising/collapsing directories, diffing against the farm's prior state, the per-identity lock and atomic-swap behaviour from [Directory rules](#directory-rules)) against a fake/in-memory filesystem; invoking the real `claude` binary via an injected `spawn` function (argv/env construction, exit-code propagation), never a real subprocess in a unit test; and the ambient-credential guard — given a fake `process.env`, refusing to proceed when any of the six named variables is set and the active identity's `allowAmbientCredential` is unset/false, proceeding when it's true, and proceeding when `CLAUDE_USE_ALLOW_AMBIENT_CREDENTIAL=1` is set for that one call regardless of the identity's own setting.

Expand Down
11 changes: 2 additions & 9 deletions src/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,14 @@ import path from "node:path";
import type { Command } from "commander";
import { z } from "zod";

import categoriesDefaultJson from "./config/categories.default.json";
import { loadClassification } from "./config/classify";
import { cosmiconfigReader } from "./config/load";
import {
CategoryClassificationOverlaySchema,
CategoryClassificationSchema,
SHIPPED_CATEGORY_DEFAULTS,
type CategoryClassification,
type CategoryClassificationOverlay,
type Identity,
} from "./config/schema";
import { readJson } from "./config/store";
import { loadCascadeInput, readDirectorySelections } from "./launcher/cascade";
import { buildEntryFacts } from "./launcher/farm";
import { AMBIENT_CREDENTIAL_VARS, evaluateAmbientCredentialGuard, type AmbientCredentialGuardResult } from "./launcher/guard";
Expand Down Expand Up @@ -406,11 +403,7 @@ export function registerCheckCommand(program: Command, paths: LayoutPaths): void
const claudeHome = resolveClaudeHome();
const read = cosmiconfigReader();

const overlay = readJson(paths.categoriesLocalFile, CategoryClassificationOverlaySchema);
const classification = {
defaults: CategoryClassificationSchema.parse(categoriesDefaultJson),
...(overlay === undefined ? {} : { overlay }),
};
const classification = loadClassification(paths);

const loaded = loadCascadeInput({ paths, home, cwd, read });
const selections = readDirectorySelections(loaded);
Expand Down
10 changes: 2 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ import path from "node:path";
import { randomUUID } from "node:crypto";
import { Command } from "commander";

import categoriesDefaultJson from "./config/categories.default.json";
import packageJson from "../package.json";
import { loadClassification } from "./config/classify";
import { cosmiconfigReader } from "./config/load";
import { CategoryClassificationOverlaySchema, CategoryClassificationSchema } from "./config/schema";
import { readJson } from "./config/store";
import { registerCheckCommand } from "./check";
import { CliError } from "./cliError";
import { realPromptsPort, registerConfigureCommand, runProfileWizard } from "./configure";
Expand Down Expand Up @@ -86,11 +84,7 @@ function buildFarmRuntime(paths: LayoutPaths): {
const home = os.homedir();
const cwd = process.cwd();
const read = cosmiconfigReader();
const overlay = readJson(paths.categoriesLocalFile, CategoryClassificationOverlaySchema);
const classification = {
defaults: CategoryClassificationSchema.parse(categoriesDefaultJson),
...(overlay === undefined ? {} : { overlay }),
};
const classification = loadClassification(paths);
const loaded = loadCascadeInput({ paths, home, cwd, read });
const selections = readDirectorySelections(loaded);
const git = resolveGitBranch(realRunPort, cwd);
Expand Down
37 changes: 35 additions & 2 deletions src/config/classify.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import categoriesDefaultJson from "./categories.default.json";
import { classifyEntries, compileClassificationPatterns, isExactPattern } from "./classify";
import { classifyEntries, compileClassificationPatterns, isExactPattern, loadClassification } from "./classify";
import { CategoryClassificationSchema } from "./schema";
import { buildLayoutPaths, type LayoutPaths } from "../paths";

const defaults = CategoryClassificationSchema.parse(categoriesDefaultJson);

Expand Down Expand Up @@ -96,3 +100,32 @@ describe("compileClassificationPatterns", () => {
expect(compiled.some((pattern) => pattern.pattern === "extra" && pattern.source === "local")).toBe(true);
});
});

describe("loadClassification", () => {
let root: string;
let paths: LayoutPaths;

beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "classify-test-"));
paths = buildLayoutPaths(root);
});

afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});

it("returns the shipped defaults with no overlay when categories.local.json does not exist", () => {
const loaded = loadClassification(paths);
expect(loaded.defaults).toEqual(defaults);
expect(loaded.overlay).toBeUndefined();
});

it("includes the local overlay once one has been written", () => {
fs.mkdirSync(path.dirname(paths.categoriesLocalFile), { recursive: true });
fs.writeFileSync(paths.categoriesLocalFile, JSON.stringify({ knowledge: ["a-user-answered-this"] }), "utf8");

const loaded = loadClassification(paths);
expect(loaded.overlay).toEqual({ knowledge: ["a-user-answered-this"] });
expect(classifyEntries(["a-user-answered-this"], loaded).classification.get("a-user-answered-this")).toBe("knowledge");
});
});
25 changes: 25 additions & 0 deletions src/config/classify.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import picomatch from "picomatch";

import categoriesDefaultJson from "./categories.default.json";
import {
CATEGORY_NAMES,
CategoryClassificationOverlaySchema,
CategoryClassificationSchema,
type CategoryClassification,
type CategoryClassificationOverlay,
type CategoryName,
} from "./schema";
import { readJson } from "./store";
import type { LayoutPaths } from "../paths";

/** Which map a classification pattern came from. A `local` pattern is an answer the user gave to an "unclassified entry" prompt; a `default` pattern is shipped with the tool. */
type ClassificationSource = "default" | "local";
Expand Down Expand Up @@ -98,6 +103,26 @@ function compareClassificationPatterns(a: ClassificationPattern, b: Classificati
return a.ordinal - b.ordinal;
}


/** The classification input every real call site needs: the shipped defaults plus this user's own local overlay, when one exists. */
export interface LoadedClassification {
readonly defaults: CategoryClassification;
readonly overlay?: CategoryClassificationOverlay;
}

/**
* Loads the classification input `classifyEntries` needs — the shipped `categories.default.json`, plus `categories.local.json` when the user has answered at least one "unclassified entry" prompt.
*
* Real-wired convenience over reading and validating the two files. Every command that classifies anything (`cli.ts`'s farm runtime, `check`, `configure`, `identity resolve`) was independently repeating this exact pair of `readJson`/`.parse()` calls before this existed; centralising it here means the four command files stay thin call sites rather than each holding its own copy of a validation step that never varies between them.
*/
export function loadClassification(paths: LayoutPaths): LoadedClassification {
const overlay = readJson(paths.categoriesLocalFile, CategoryClassificationOverlaySchema);
return {
defaults: CategoryClassificationSchema.parse(categoriesDefaultJson),
...(overlay === undefined ? {} : { overlay }),
};
}

/**
* Classifies a list of real top-level `~/.claude` entry names against the shipped category map plus an optional local overlay.
*
Expand Down
10 changes: 2 additions & 8 deletions src/configure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import path from "node:path";
import type { Command } from "commander";
import * as clack from "@clack/prompts";

import categoriesDefaultJson from "./config/categories.default.json";
import { loadClassification } from "./config/classify";
import { cosmiconfigReader } from "./config/load";
import {
CategoryClassificationOverlaySchema,
CategoryClassificationSchema,
OVERRIDABLE_CATEGORIES,
PortableConfigSchema,
SHIPPED_CATEGORY_DEFAULTS,
Expand Down Expand Up @@ -362,11 +360,7 @@ function buildConfigureContext(deps: RunConfigureDeps, params: RunConfigureParam
}

const read = cosmiconfigReader();
const overlay = readJson(deps.paths.categoriesLocalFile, CategoryClassificationOverlaySchema);
const classification = {
defaults: CategoryClassificationSchema.parse(categoriesDefaultJson),
...(overlay === undefined ? {} : { overlay }),
};
const classification = loadClassification(deps.paths);

const globalConfig = readGlobalConfig(deps.paths);
const preliminary = loadCascadeInput({ paths: deps.paths, home: params.home, cwd: params.cwd, read });
Expand Down
12 changes: 11 additions & 1 deletion src/identityManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import type { Command } from "commander";

import { loadClassification } from "./config/classify";
import { ConfigValidationError } from "./config/load";
import { applyPatch, readJson, writeJsonAtomic, writeTextAtomic } from "./config/store";
import { IdentitySchema, type Identity } from "./config/schema";
Expand Down Expand Up @@ -302,6 +303,7 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v
fs: realFarmFs,
identitiesDir: paths.identitiesDir,
identity: name,
classification: loadClassification(paths),
decide: async (conflict) => {
const choice = await realPromptsPort.select<FarmConflictChoice>({
message:
Expand All @@ -317,8 +319,16 @@ export function registerIdentityCommand(program: Command, paths: LayoutPaths): v
},
});

if (result.autoResolved.length > 0) {
console.log(
`Auto-resolved ${result.autoResolved.length} disposable runtime entr${result.autoResolved.length === 1 ? "y" : "ies"} ` +
`with no prompt (${result.autoResolved.join(", ")}) — per-process/per-machine state, never worth asking about.`,
);
}
if (result.resolved.length === 0) {
console.log(`No superseded farm data to resolve for identity "${name}".`);
if (result.autoResolved.length === 0) {
console.log(`No superseded farm data to resolve for identity "${name}".`);
}
return;
}
for (const conflict of result.resolved) {
Expand Down
1 change: 1 addition & 0 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ export function runLauncher(params: RunLauncherParams): void {
identity: farmIdentity,
now: farm.now,
lock: farm.lock,
classification: farm.classification,
});
} catch (error) {
if (error instanceof IdentityLockBusyError) {
Expand Down
Loading
Loading