agent-shim 0.0.0 → 7.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +233 -0
- package/dist/cli.cjs +69549 -0
- package/dist/index.cjs +3620 -0
- package/dist/index.mjs +3545 -0
- package/dist/types/cliError.d.ts +27 -0
- package/dist/types/config/classify.d.ts +49 -0
- package/dist/types/config/load.d.ts +42 -0
- package/dist/types/config/schema.d.ts +746 -0
- package/dist/types/config/store.d.ts +46 -0
- package/dist/types/frontdoor/capability.d.ts +19 -0
- package/dist/types/frontdoor/connect.d.ts +179 -0
- package/dist/types/frontdoor/ensure.d.ts +34 -0
- package/dist/types/frontdoor/probe.d.ts +13 -0
- package/dist/types/frontdoor/state.d.ts +50 -0
- package/dist/types/frontdoor/supervisor.d.ts +48 -0
- package/dist/types/frontdoor/x509.d.ts +11 -0
- package/dist/types/headroom/ensure.d.ts +31 -0
- package/dist/types/headroom/settings.d.ts +13 -0
- package/dist/types/headroom/source.d.ts +8 -0
- package/dist/types/headroom/state.d.ts +77 -0
- package/dist/types/headroom/supervisor.d.ts +107 -0
- package/dist/types/index.d.ts +21 -0
- package/dist/types/launcher/farm.d.ts +165 -0
- package/dist/types/launcher/guard.d.ts +57 -0
- package/dist/types/launcher/lock.d.ts +41 -0
- package/dist/types/launcher/ports.d.ts +135 -0
- package/dist/types/legacy.d.ts +24 -0
- package/dist/types/paths.d.ts +79 -0
- package/dist/types/resolve/conditions.d.ts +37 -0
- package/dist/types/resolve/decide.d.ts +45 -0
- package/dist/types/resolve/extends.d.ts +41 -0
- package/dist/types/resolve/flatten.d.ts +13 -0
- package/dist/types/resolve/match.d.ts +58 -0
- package/dist/types/resolve/pipeline.d.ts +30 -0
- package/dist/types/resolve/plan.d.ts +57 -0
- package/dist/types/resolve/projects.d.ts +61 -0
- package/dist/types/resolve/reconcile.d.ts +80 -0
- package/dist/types/resolve/types.d.ts +159 -0
- package/dist/types/resolve/walk.d.ts +85 -0
- package/dist/types/usage/account.d.ts +20 -0
- package/dist/types/usage/read.d.ts +70 -0
- package/dist/types/usage/schema.d.ts +318 -0
- package/package.json +91 -2
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The resolved set of paths agent-shim reads and writes under its own root.
|
|
3
|
+
*
|
|
4
|
+
* `root` is always AGENT_SHIM_HOME when that environment variable is set (used by every test in this project, and by any real installation that wants to relocate its state), falling back to `~/.agent-shim` (or the former `~/.claude-use`, see `resolveAgentShimHome`) only when the variable is unset. Every other field is derived from `root` so there is exactly one place a path can go wrong.
|
|
5
|
+
*/
|
|
6
|
+
export interface LayoutPaths {
|
|
7
|
+
/** The resolved root directory, see `resolveAgentShimHome`. */
|
|
8
|
+
readonly root: string;
|
|
9
|
+
/** Directory holding one subdirectory per identity (symlink farm + local credentials/daemon state). */
|
|
10
|
+
readonly identitiesDir: string;
|
|
11
|
+
/** Directory holding named, reusable configuration profile JSON files. */
|
|
12
|
+
readonly configProfilesDir: string;
|
|
13
|
+
/** Directory holding one `<name>.json` provider definition per API provider. */
|
|
14
|
+
readonly providersDir: string;
|
|
15
|
+
/** Path to the directory-rules.json file describing directory-scoped identity/profile pins. */
|
|
16
|
+
readonly directoryRulesFile: string;
|
|
17
|
+
/** Path to the persisted active-identity file (the identity `agent-shim identity use` selected). */
|
|
18
|
+
readonly activeIdentityFile: string;
|
|
19
|
+
/** Path to the global config.json (user-global override layer, and default profile/walk-limit settings). */
|
|
20
|
+
readonly globalConfigFile: string;
|
|
21
|
+
/** Path to the categories.local.json overlay recording answers to "unclassified entry" prompts. */
|
|
22
|
+
readonly categoriesLocalFile: string;
|
|
23
|
+
/** Path to the claude-shim.json marker recording where `agent-shim shim enable` last placed a `claude`-named copy of this executable, and how. */
|
|
24
|
+
readonly claudeShimFile: string;
|
|
25
|
+
/** Directory holding the headroom daemon's coordination state: state.json, the start lock, and the session registry. */
|
|
26
|
+
readonly headroomDir: string;
|
|
27
|
+
/** Path to the headroom supervisor's state.json: pids, port, version, allowlist hash, last error. */
|
|
28
|
+
readonly headroomStateFile: string;
|
|
29
|
+
/** Path to the exclusive-create marker guarding "who spawns the supervisor" so concurrent launches start at most one. */
|
|
30
|
+
readonly headroomLockFile: string;
|
|
31
|
+
/** Directory holding one `<launcher-pid>.json` session-registry entry per live launch routed through headroom. */
|
|
32
|
+
readonly headroomSessionsDir: string;
|
|
33
|
+
/** Directory holding daemon logs (the headroom proxy's stdout/stderr and the supervisor's own output). */
|
|
34
|
+
readonly logsDir: string;
|
|
35
|
+
/** Path to the headroom daemon's combined log, appended to by both the supervisor and the proxy it owns. */
|
|
36
|
+
readonly headroomLogPath: string;
|
|
37
|
+
/** Directory holding the front-door daemon's coordination state: state.json, the start lock, and the session registry. */
|
|
38
|
+
readonly frontdoorDir: string;
|
|
39
|
+
/** Directory holding the front door's certificate authority, which signs both the provider listener's loopback leaf and the CONNECT surface's intercept leaf: ca.pem (public, handed to children through NODE_EXTRA_CA_CERTS) and ca.key (mode 0600). */
|
|
40
|
+
readonly frontdoorCaDir: string;
|
|
41
|
+
/** Path to the front door's CA certificate, generated once and stable across restarts so children keep trusting it. */
|
|
42
|
+
readonly frontdoorCaCertFile: string;
|
|
43
|
+
/** Path to the front door's CA private key, mode 0600: whoever can read it can impersonate the door to every routed child. */
|
|
44
|
+
readonly frontdoorCaKeyFile: string;
|
|
45
|
+
/** Directory of combined CA bundles: a parent environment's own NODE_EXTRA_CA_CERTS file plus the front door's CA, one file per distinct combination, for children that must keep trusting both. */
|
|
46
|
+
readonly frontdoorCaBundlesDir: string;
|
|
47
|
+
/** Path to the front door's state.json: its supervisor pid, its port, and the sticky port it last served on. */
|
|
48
|
+
readonly frontdoorStateFile: string;
|
|
49
|
+
/** Path to the exclusive-create marker guarding "who spawns the front-door supervisor" so concurrent launches start at most one. */
|
|
50
|
+
readonly frontdoorLockFile: string;
|
|
51
|
+
/** Directory holding one `<launcher-pid>.json` session-registry entry per live launch routed through the front door. */
|
|
52
|
+
readonly frontdoorSessionsDir: string;
|
|
53
|
+
/** Path to the front door's log: one line per lifecycle event and routed failure, never a token. */
|
|
54
|
+
readonly frontdoorLogPath: string;
|
|
55
|
+
/** Directory holding the usage store, mode 0700: the request log and the per-identity snapshots. Metadata only, never content or credentials. */
|
|
56
|
+
readonly usageDir: string;
|
|
57
|
+
/** Directory of the append-only usage log: one `<YYYY-MM-DD>.<writer-pid>.jsonl` segment per writing process per UTC day. */
|
|
58
|
+
readonly usageLogDir: string;
|
|
59
|
+
/** Directory of the latest-state snapshots, one `<identity>.json` per identity: the stable file other tools read. */
|
|
60
|
+
readonly usageSnapshotsDir: string;
|
|
61
|
+
/** The last identity picked from a pool for each directory, so a conversation stays on the account whose prompt cache is warm. */
|
|
62
|
+
readonly usagePicksFile: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolves the state root: `AGENT_SHIM_HOME` when set to a non-empty string (an empty string counts as unset, consistent with how this project treats empty-string environment variables elsewhere, see the ambient-credential guard), otherwise `~/.agent-shim`, or the former `~/.claude-use` when only that exists.
|
|
66
|
+
*
|
|
67
|
+
* The former root is used where it stands, never moved: macOS Claude Code names each identity's Keychain entry after a hash of its exact `CLAUDE_CONFIG_DIR`, so any relocation would sign every identity out. A fresh installation, or one that already has `~/.agent-shim`, uses the current name.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveAgentShimHome(env?: NodeJS.ProcessEnv, home?: string, isDirectory?: (candidate: string) => boolean): string;
|
|
70
|
+
/**
|
|
71
|
+
* Resolves the canonical `~/.claude` directory every farm symlink points back into: `AGENT_SHIM_CLAUDE_HOME` when set to a non-empty string, otherwise `~/.claude`.
|
|
72
|
+
*
|
|
73
|
+
* The override exists for the same reason `AGENT_SHIM_HOME` does. Exercising a real resync end to end means building and swapping real directories, and doing that against a real, in-use `~/.claude` to find out whether the code is correct is not an acceptable way to find out. Pointing both variables at throwaway directories makes a full end-to-end run safe.
|
|
74
|
+
*/
|
|
75
|
+
export declare function resolveClaudeHome(): string;
|
|
76
|
+
/** Builds the full LayoutPaths structure from a given root directory. */
|
|
77
|
+
export declare function buildLayoutPaths(root: string): LayoutPaths;
|
|
78
|
+
/** Resolves AGENT_SHIM_HOME and builds the full LayoutPaths structure in one call. */
|
|
79
|
+
export declare function resolveLayoutPaths(): LayoutPaths;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type WhenCondition } from "../config/schema";
|
|
2
|
+
import type { EntryFact } from "./types";
|
|
3
|
+
/** Parses a duration literal like `90d` or `500ms` into milliseconds. Throws on anything the schema's own regex would already have rejected, so a malformed value can never be silently treated as zero. */
|
|
4
|
+
export declare function parseDuration(value: string): number;
|
|
5
|
+
/** True when the value would satisfy the schema's duration regex. */
|
|
6
|
+
export declare function isDuration(value: string): boolean;
|
|
7
|
+
/** Everything a `when` condition can be evaluated against, injected rather than read. */
|
|
8
|
+
export interface ConditionContext {
|
|
9
|
+
readonly nowMs: number;
|
|
10
|
+
/** Facts about the specific entry being decided. Absent when evaluating a rule-level condition that has no single entry (a directory rule's own `when`). */
|
|
11
|
+
readonly fact?: EntryFact;
|
|
12
|
+
/** The branch checked out at `cwd`, or undefined when `cwd` is not in a repository. */
|
|
13
|
+
readonly branch?: string;
|
|
14
|
+
/** True when the repository is in detached-HEAD state, in which case no `branch` condition can match. */
|
|
15
|
+
readonly branchDetached?: boolean;
|
|
16
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
17
|
+
}
|
|
18
|
+
/** The result of evaluating one `when` object. */
|
|
19
|
+
export interface WhenEvaluation {
|
|
20
|
+
readonly passed: boolean;
|
|
21
|
+
/** Which condition fields were present and evaluated. */
|
|
22
|
+
readonly checked: readonly string[];
|
|
23
|
+
/** Which of those fields did not hold. Empty when `passed` is true. */
|
|
24
|
+
readonly failed: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/** True when `branch` matches `pattern`. The pattern is glob-capable (`client/*`); a detached HEAD or a non-repository directory never matches. */
|
|
27
|
+
export declare function matchBranch(pattern: string, branch: string | undefined, detached?: boolean): boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Evaluates a `when` object. Every present field must hold — conditions AND together within one object.
|
|
30
|
+
*
|
|
31
|
+
* An absent condition is vacuously true, so `when: {}` passes; `agent-shim check` warns about that rather than erroring, since an empty object is more likely a half-finished edit than an intentional statement.
|
|
32
|
+
*
|
|
33
|
+
* `newerThan`, `olderThan`, and `maxSizeBytes` read the subtree-aggregated facts (`latestMtimeMs`, `totalSizeBytes`), never the entry's own inode stat: a directory's own mtime does not change when a file three levels beneath it is rewritten, and its own size is a ~4KB inode figure that says nothing about what it contains.
|
|
34
|
+
*/
|
|
35
|
+
export declare function evaluateWhen(when: WhenCondition | undefined, context: ConditionContext): WhenEvaluation;
|
|
36
|
+
/** True when the `when` object is present but empty, which is vacuously true and therefore has no effect. */
|
|
37
|
+
export declare function isVacuousWhen(when: WhenCondition | undefined): boolean;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type CategoryName } from "../config/schema";
|
|
2
|
+
import type { CompiledRule, Decision, Diagnostic, EliminatedRule, EntryFact, EntryFacts, FlattenedCascade } from "./types";
|
|
3
|
+
/** Everything phase two needs, all of it injected. */
|
|
4
|
+
export interface DecideParams {
|
|
5
|
+
readonly flattened: FlattenedCascade;
|
|
6
|
+
readonly facts: EntryFacts;
|
|
7
|
+
/** Every top-level `~/.claude` entry name mapped to its category, or to null when nothing recognises it. */
|
|
8
|
+
readonly classification: ReadonlyMap<string, CategoryName | null>;
|
|
9
|
+
}
|
|
10
|
+
/** A rule chosen by `selectRule`, alongside the candidates eliminated on the way to it. */
|
|
11
|
+
export interface RuleSelection {
|
|
12
|
+
readonly rule?: CompiledRule;
|
|
13
|
+
readonly eliminated: readonly EliminatedRule[];
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Picks the winning entries rule for one path.
|
|
17
|
+
*
|
|
18
|
+
* Candidates are ranked by `compareSpecificity` and taken in order. A candidate whose `when` condition **fails is eliminated** and resolution continues to the next-most-specific matching rule — it never falls straight through to the category default, and a failed condition never inverts the rule's boolean value, which would not have a coherent meaning anyway. Only when no candidate survives does the caller fall back to the category.
|
|
19
|
+
*/
|
|
20
|
+
export declare function selectRule(relPath: string, flattened: FlattenedCascade, context: {
|
|
21
|
+
nowMs: number;
|
|
22
|
+
branch?: string;
|
|
23
|
+
branchDetached?: boolean;
|
|
24
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
25
|
+
fact?: EntryFact;
|
|
26
|
+
}): RuleSelection;
|
|
27
|
+
/**
|
|
28
|
+
* Resolves one path to a sharing decision, in exactly this order:
|
|
29
|
+
*
|
|
30
|
+
* 1. **The secret floor.** If the path's top-level entry classifies as `secret`, the answer is `false` unconditionally. This check sits *above* the entries lookup, not merely at the least-specific end of the cascade, and it fires on the path's **real classification**, never on a lexical reading of the key that matched it — so a glob written under a `runtime/` prefix that happens to reach `.credentials.json` is neutralised exactly like a deliberate `secret/` key would be.
|
|
31
|
+
* 2. **Unclassified.** A top-level entry nothing recognises is not shared, and is reported rather than silently guessed at.
|
|
32
|
+
* 3. **Entries rules**, most specific first, with a failing condition eliminating a candidate rather than ending the search.
|
|
33
|
+
* 4. **The category**: a layer's own toggle if any layer set one, otherwise the shipped default.
|
|
34
|
+
*/
|
|
35
|
+
export declare function resolveEntry(relPath: string, params: DecideParams): {
|
|
36
|
+
decision: Decision;
|
|
37
|
+
diagnostics: Diagnostic[];
|
|
38
|
+
};
|
|
39
|
+
/** Every decision the resolver reaches, plus every diagnostic raised along the way (phase-one diagnostics included, and de-duplicated). */
|
|
40
|
+
export interface ResolveAllResult {
|
|
41
|
+
readonly decisions: ReadonlyMap<string, Decision>;
|
|
42
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
43
|
+
}
|
|
44
|
+
/** Resolves every entry in the fact manifest. */
|
|
45
|
+
export declare function resolveAll(params: DecideParams): ResolveAllResult;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ConfigProfile } from "../config/schema";
|
|
2
|
+
import type { Diagnostic, Layer, LayerId } from "./types";
|
|
3
|
+
/** One configuration profile as loaded from disk, with its entries key order captured at load time. */
|
|
4
|
+
export interface ProfileSource {
|
|
5
|
+
readonly name: string;
|
|
6
|
+
readonly profile: ConfigProfile;
|
|
7
|
+
readonly entryOrder?: readonly string[];
|
|
8
|
+
/** Where it was loaded from, for diagnostics. Defaults to the profile name. */
|
|
9
|
+
readonly filepath?: string;
|
|
10
|
+
}
|
|
11
|
+
/** Loads a profile by name. Injected, so nothing in the resolver reads a file. Returns undefined when no profile of that name exists. */
|
|
12
|
+
export type ProfileLoader = (name: string) => ProfileSource | undefined;
|
|
13
|
+
/** The linearised `extends` chain plus anything that went wrong resolving it. */
|
|
14
|
+
export interface LinearisedProfiles {
|
|
15
|
+
/** Profile names, base-first: every profile appears exactly once, after everything it extends. */
|
|
16
|
+
readonly order: readonly string[];
|
|
17
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Linearises a profile's `extends` graph into a base-first order.
|
|
21
|
+
*
|
|
22
|
+
* The walker keeps **two separate structures**, and the distinction is load-bearing rather than stylistic:
|
|
23
|
+
*
|
|
24
|
+
* - `stack` is the current depth-first path. A name already on it means the graph genuinely loops back on itself, which is the only real cycle.
|
|
25
|
+
* - `visited` is every name already emitted anywhere in the walk. A name already in it is a diamond — two branches sharing a base — and must simply be skipped, not re-emitted.
|
|
26
|
+
*
|
|
27
|
+
* Collapsing the two into one shared set produces a silent wrong answer in either direction. Using `stack` alone re-emits a shared base a second time in a diamond, so the base's values get re-applied on top of an intermediate profile that deliberately overrode them — the override silently disappears. Using `visited` alone false-flags every legitimate diamond as a cycle. Neither failure crashes; both just resolve to the wrong configuration.
|
|
28
|
+
*
|
|
29
|
+
* Emission is **post-order**: a profile is appended only after everything it extends, so `c extends [a, b]` where both extend `base` linearises to `[base, a, b, c]` — never `[base, a, base, b, c]`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function lineariseProfile(rootName: string, load: ProfileLoader): LinearisedProfiles;
|
|
32
|
+
/**
|
|
33
|
+
* Turns a profile's `extends` chain into cascade layers, base-first, starting at `startId`.
|
|
34
|
+
*
|
|
35
|
+
* A resolved profile is not a separate mechanism from the outer cascade: its chain flattens through the same ordered-layer sequence everything else uses, so a profile's resolved patch is simply one more run of inputs to the same algorithm.
|
|
36
|
+
*/
|
|
37
|
+
export declare function profileLayers(rootName: string, load: ProfileLoader, startId: LayerId): {
|
|
38
|
+
layers: Layer[];
|
|
39
|
+
nextId: LayerId;
|
|
40
|
+
diagnostics: readonly Diagnostic[];
|
|
41
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CompiledRule, FlattenedCascade, Layer } from "./types";
|
|
2
|
+
/**
|
|
3
|
+
* Cascade phase one: walk the ordered layer sequence once, spreading each layer's `categories` and `entries` over an accumulator. A later layer's value for the exact same category name, or the exact same key, replaces an earlier layer's value for that identical key. No path-specificity reasoning happens here — that is phase two's job.
|
|
4
|
+
*
|
|
5
|
+
* The entries accumulator is keyed by the **canonical** pattern, not the raw written key, so two differently-spelled but equivalent keys (`history/projects/~/work/x` and `history/projects/$HOME/work/x` after expansion) collapse into one rule here instead of racing as two separate candidates in phase two. Every surviving rule carries its own `{ layer, ordinal }`, which is what lets phase two tell a same-layer conflict from a cross-layer one without a separate provenance side-table.
|
|
6
|
+
*
|
|
7
|
+
* A key written under the `secret/` prefix is rejected outright with its own diagnostic and contributes no rule at all: `secret` is the one category no layer may open, and a deliberate attempt to name a secret path deserves a clearer error than the silent neutralisation the resolve-time floor check applies to a glob that reaches one incidentally.
|
|
8
|
+
*/
|
|
9
|
+
export declare function flattenLayers(layers: readonly Layer[], options: Readonly<{
|
|
10
|
+
home: string;
|
|
11
|
+
}>): FlattenedCascade;
|
|
12
|
+
/** Every compiled rule that matches `relPath`, ranked most-specific first. */
|
|
13
|
+
export declare function matchingRules(flattened: FlattenedCascade, relPath: string): CompiledRule[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type CategoryName } from "../config/schema";
|
|
2
|
+
import { CliError } from "../cliError";
|
|
3
|
+
import type { CompiledRule } from "./types";
|
|
4
|
+
/** True when `pattern` contains no glob metacharacter, so it can only ever match one literal path. */
|
|
5
|
+
export declare function isExactPattern(pattern: string): boolean;
|
|
6
|
+
/** The pattern's leading run of characters before the first glob metacharacter — the part that is definitely literal. */
|
|
7
|
+
export declare function literalPrefixOf(pattern: string): string;
|
|
8
|
+
/** Normalises a relative path fragment: collapses repeated slashes, drops `./` segments, and strips a trailing slash. Deliberately case-preserving and case-sensitive. */
|
|
9
|
+
export declare function normaliseRelative(fragment: string): string;
|
|
10
|
+
/** The outcome of canonicalising one entries key. */
|
|
11
|
+
export interface CanonicalKey {
|
|
12
|
+
readonly declaredCategory: CategoryName;
|
|
13
|
+
/** The pattern with its category prefix stripped, relative to `~/.claude`, with any `history/projects/` path fragment already encoded. */
|
|
14
|
+
readonly canonicalPattern: string;
|
|
15
|
+
/** The `history/projects/` path fragment as written, before encoding — retained so ambiguity reporting can name what the author actually typed. */
|
|
16
|
+
readonly projectFragment?: string;
|
|
17
|
+
}
|
|
18
|
+
/** Raised when an entries key cannot be canonicalised. Carries the offending key so the diagnostic can name it. */
|
|
19
|
+
export declare class EntryKeyError extends CliError {
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly reason: "unrooted-project-path" | "malformed";
|
|
22
|
+
constructor(key: string, message: string, reason: "unrooted-project-path" | "malformed");
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Canonicalises one entries key into a pattern over paths relative to `~/.claude`.
|
|
26
|
+
*
|
|
27
|
+
* Every key is `<category>/<real-relative-path>`, so the category prefix is stripped and the remainder kept as written — with one deliberately narrow exception. Anything written after the literal `history/projects/` prefix is a real absolute working directory (optionally globbed), not a literal child directory name, because that directory's only real children are Claude Code's own encoded names and there is nothing else meaningful to reference there. Those fragments get `~`-expanded and forward-encoded; every other key in the whole design is a plain literal path or an ordinary glob over one, matched exactly as written.
|
|
28
|
+
*/
|
|
29
|
+
export declare function canonicaliseEntryKey(key: string, options: Readonly<{
|
|
30
|
+
home: string;
|
|
31
|
+
}>): CanonicalKey;
|
|
32
|
+
/**
|
|
33
|
+
* Compiles a canonical pattern into a matcher that covers the pattern itself *and* everything beneath it, so a rule written as `knowledge/skills` decides `skills/commit/SKILL.md` too. Two picomatch matchers are needed rather than one: `skills/**` alone covers the subtree but `projects/*` does not match `projects/x/y`, and `projects/*` alone does not cover the files inside a matched project directory.
|
|
34
|
+
*
|
|
35
|
+
* Matching is deliberately case-sensitive (`nocase: false`) whatever the host filesystem does, so the same config behaves identically on a case-insensitive APFS volume and a case-sensitive one. `dot: true` so a glob can match a leading-dot entry.
|
|
36
|
+
*/
|
|
37
|
+
export declare function compileMatcher(canonicalPattern: string): (relPath: string) => boolean;
|
|
38
|
+
/**
|
|
39
|
+
* True when some path *underneath* `dir` could match `canonicalPattern`, whether or not such a path exists today.
|
|
40
|
+
*
|
|
41
|
+
* This is what makes a conditional rule force materialisation: a directory Claude Code will write new children into cannot be symlinked wholesale if a rule might have something to say about those not-yet-existing children. Answering that from the pattern's structure rather than from the current file listing is the whole point — asking "does any existing child match" would pass the obvious test and fail the real one.
|
|
42
|
+
*/
|
|
43
|
+
export declare function patternCouldReachUnder(canonicalPattern: string, dir: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Ranks two compiled rules that both match the same path. A positive result means `a` wins.
|
|
46
|
+
*
|
|
47
|
+
* The order, exactly:
|
|
48
|
+
*
|
|
49
|
+
* 1. **Different layers — the later layer wins, period.** This rank sits deliberately *above* exactness. Ranking exactness first would let an untrusted committed `.agent-shim.json`'s exact key beat your own later, personal glob override, breaking the design's stated trust property that a directory-scoped local rule can only ever tighten what a committed file opened, never the reverse. Nothing in the design asserts that an earlier exact key beats a later glob, so nothing is lost by closing that hole.
|
|
50
|
+
* 2. Same layer: an exact literal beats a glob.
|
|
51
|
+
* 3. Same layer: the longer literal prefix wins.
|
|
52
|
+
* 4. Same layer: more path segments wins, which separates a one-wildcard pattern from a two-wildcard one at the same depth.
|
|
53
|
+
* 5. Same layer: the later ordinal — source order within the file — wins.
|
|
54
|
+
* 6. Canonical pattern, compared as a string, as a last-resort determinism guarantee.
|
|
55
|
+
*
|
|
56
|
+
* The order must be **total**: returning 0 for two distinct rules would make the winner depend on iteration order, which tests would only catch intermittently.
|
|
57
|
+
*/
|
|
58
|
+
export declare function compareSpecificity(a: CompiledRule, b: CompiledRule): number;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ClassifyResult } from "../config/classify";
|
|
2
|
+
import type { CategoryClassification, CategoryClassificationOverlay } from "../config/schema";
|
|
3
|
+
import { type ResolveAllResult } from "./decide";
|
|
4
|
+
import { type FarmPlan } from "./plan";
|
|
5
|
+
import { type AssembledCascade, type CascadeInput } from "./walk";
|
|
6
|
+
import type { Diagnostic, EntryFacts, FlattenedCascade } from "./types";
|
|
7
|
+
/** Everything one launch needs resolved, in one call. */
|
|
8
|
+
export interface ResolveDecisionsInput {
|
|
9
|
+
readonly facts: EntryFacts;
|
|
10
|
+
readonly cascade: CascadeInput;
|
|
11
|
+
readonly classification: {
|
|
12
|
+
readonly defaults: CategoryClassification;
|
|
13
|
+
readonly overlay?: CategoryClassificationOverlay;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
/** The fully resolved state of one launch: the layers that composed it, every entry's decision, the farm layout, and everything worth reporting. */
|
|
17
|
+
export interface ResolvedState {
|
|
18
|
+
readonly assembled: AssembledCascade;
|
|
19
|
+
readonly flattened: FlattenedCascade;
|
|
20
|
+
readonly classification: ClassifyResult;
|
|
21
|
+
readonly decisions: ResolveAllResult["decisions"];
|
|
22
|
+
readonly farm: FarmPlan;
|
|
23
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
24
|
+
}
|
|
25
|
+
/** The top-level `~/.claude` entry names present in a fact manifest, in sorted order. */
|
|
26
|
+
export declare function topLevelNames(facts: EntryFacts): string[];
|
|
27
|
+
/**
|
|
28
|
+
* Runs the whole pipeline for one launch: assemble the ordered layer sequence, flatten it, classify the real `~/.claude` entries, decide every path, and plan the resulting farm.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveDecisions(input: ResolveDecisionsInput): ResolvedState;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Decision, EntryFacts, FlattenedCascade } from "./types";
|
|
2
|
+
/** What the farm should contain at one path. */
|
|
3
|
+
type FarmPlanEntry =
|
|
4
|
+
/** A single symlink standing in for this whole path — always absolute, always under `claudeHome`. */
|
|
5
|
+
{
|
|
6
|
+
readonly kind: "link";
|
|
7
|
+
readonly rel: string;
|
|
8
|
+
readonly target: string;
|
|
9
|
+
}
|
|
10
|
+
/** A real local directory the farm builds itself, because the decision beneath it is not uniform or because a conditional rule could touch it. */
|
|
11
|
+
| {
|
|
12
|
+
readonly kind: "materialise";
|
|
13
|
+
readonly rel: string;
|
|
14
|
+
readonly reason: MaterialiseReason;
|
|
15
|
+
}
|
|
16
|
+
/** Nothing at all in the farm for this path. */
|
|
17
|
+
| {
|
|
18
|
+
readonly kind: "omit";
|
|
19
|
+
readonly rel: string;
|
|
20
|
+
};
|
|
21
|
+
/** Why a directory has to be built rather than symlinked. */
|
|
22
|
+
type MaterialiseReason =
|
|
23
|
+
/** Descendants of this directory resolve to different decisions, so a single symlink cannot express the outcome. */
|
|
24
|
+
"split-decision"
|
|
25
|
+
/** A conditional entries rule could match something under this directory, so the decision has to stay live. */
|
|
26
|
+
| "conditional-rule";
|
|
27
|
+
/** The full farm plan for one resolved cascade. */
|
|
28
|
+
export interface FarmPlan {
|
|
29
|
+
/** Every planned path, parents before children. */
|
|
30
|
+
readonly entries: readonly FarmPlanEntry[];
|
|
31
|
+
/** Directories the farm builds itself, in plan order. Written into the farm manifest so the next resync can tell them apart from directories Claude Code created. */
|
|
32
|
+
readonly materialised: readonly string[];
|
|
33
|
+
readonly links: readonly {
|
|
34
|
+
readonly rel: string;
|
|
35
|
+
readonly target: string;
|
|
36
|
+
}[];
|
|
37
|
+
}
|
|
38
|
+
/** Everything `planFarm` needs, all of it already resolved. */
|
|
39
|
+
export interface PlanFarmParams {
|
|
40
|
+
readonly facts: EntryFacts;
|
|
41
|
+
readonly decisions: ReadonlyMap<string, Decision>;
|
|
42
|
+
readonly flattened: FlattenedCascade;
|
|
43
|
+
}
|
|
44
|
+
/** Indexes the fact manifest by parent path, so the plan can descend the tree without touching a filesystem. */
|
|
45
|
+
export declare function buildChildIndex(facts: EntryFacts): Map<string, string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Turns a resolved set of decisions into a concrete farm layout.
|
|
48
|
+
*
|
|
49
|
+
* A directory becomes one symlink when the decision is uniform across its whole subtree, which is what keeps the common resync cheap — a few dozen top-level symlinks rather than a rebuilt tree. It is materialised, and recursed into, when either:
|
|
50
|
+
*
|
|
51
|
+
* - its subtree's decisions genuinely disagree, so no single symlink can express the outcome; or
|
|
52
|
+
* - **any** conditional entries rule could reach something under it, even when today's evaluation happens to be uniform. This second case is the one a naive "is the decision uniform right now" check gets wrong: a symlinked directory freezes the decision for every file Claude Code creates *after* the resync, so a `newerThan` condition meant to share recent sessions would never see a session written five minutes from now. The bug only surfaces for files that do not exist yet, which is precisely why the check has to be structural rather than observational.
|
|
53
|
+
*
|
|
54
|
+
* Every symlink target is `<claudeHome>/<rel>`, unconditionally. That matters for the entries inside a materialised directory that are themselves relative symlinks escaping `~/.claude` (a real condition, not a hypothetical one — a skills directory linked out to a separate dotfiles repository, for instance). Copying such a link's own relative target verbatim would resolve to a different place from the farm's own depth, and copying its resolved absolute target would pin the farm to wherever that link pointed at resync time. Linking at `<claudeHome>/<rel>` and letting the OS follow the remaining hops from there is the only form that stays correct.
|
|
55
|
+
*/
|
|
56
|
+
export declare function planFarm(params: PlanFarmParams): FarmPlan;
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forward-only encoding of a real working directory into the single directory name Claude Code gives it under `~/.claude/projects/`.
|
|
3
|
+
*
|
|
4
|
+
* The encoding collapses the *entire* non-alphanumeric character class to `-`, not just the path separator: `.`, `_`, ` `, `@`, and `/` all become `-`, while letters and digits pass through with their case preserved. This was confirmed against a real installation's project directories, not assumed from the one `/`-becomes-`-` sample the README quotes.
|
|
5
|
+
*
|
|
6
|
+
* The encoding is therefore many-to-one: `~/work/clients/acme`, `~/work/clients-acme`, and `~/work-clients/acme` all produce the identical name. That makes it impossible to invert, so this module never tries: there is no decode function here, and `projects.test.ts` asserts the module's export list to keep it that way. Only the forward direction — real path to encoded name — is ever computed, and `detectEncodingAmbiguity` reports when a pattern's encoded form could plausibly correspond to more than one real path rather than resolving it silently.
|
|
7
|
+
*/
|
|
8
|
+
import { CliError } from "../cliError";
|
|
9
|
+
/** Encodes one real, fully-literal path into the directory name Claude Code would give it under `~/.claude/projects/`. */
|
|
10
|
+
export declare function encodeProjectPath(realPath: string): string;
|
|
11
|
+
/** One piece of a pattern split on its wildcard tokens: either a literal run to encode, or a wildcard to leave alone. */
|
|
12
|
+
export interface PatternFragment {
|
|
13
|
+
readonly kind: "literal" | "wildcard";
|
|
14
|
+
readonly text: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Splits a pattern into alternating literal runs and wildcard tokens. Encoding the whole pattern string in one pass would turn `*` into `-` along with everything else, silently converting a glob into a literal that matches nothing.
|
|
18
|
+
*/
|
|
19
|
+
export declare function splitOnWildcards(pattern: string): PatternFragment[];
|
|
20
|
+
/** Raised when a `history/projects/` pattern's path fragment is neither `~`-rooted nor absolute, so there is no real path to encode. */
|
|
21
|
+
export declare class UnrootedProjectPathError extends CliError {
|
|
22
|
+
readonly fragment: string;
|
|
23
|
+
constructor(fragment: string);
|
|
24
|
+
}
|
|
25
|
+
/** Expands a leading `~` to the given home directory. A `~` anywhere else in the path is an ordinary character. */
|
|
26
|
+
export declare function expandHome(fragment: string, home: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Encodes a possibly-globbed real path into a pattern that matches Claude Code's own `~/.claude/projects/` directory names.
|
|
29
|
+
*
|
|
30
|
+
* Order matters and is fixed: expand `~` to the real home first (encoding `~` would turn it into `-` and lose the reference), reject anything not home-or-root-rooted, then split on wildcard tokens and encode only the literal runs.
|
|
31
|
+
*/
|
|
32
|
+
export declare function encodeProjectPattern(fragment: string, options: Readonly<{
|
|
33
|
+
home: string;
|
|
34
|
+
}>): string;
|
|
35
|
+
/** Why a pattern's encoded form might not identify the real path its author had in mind. */
|
|
36
|
+
export type EncodingAmbiguityReason =
|
|
37
|
+
/** The literal portion contains a character other than `/` that encodes to `-`, so a sibling path differing only in that character encodes identically. */
|
|
38
|
+
"lossy-characters"
|
|
39
|
+
/** Two different patterns in the same configuration encode to the identical form. */
|
|
40
|
+
| "collides-with-sibling-pattern";
|
|
41
|
+
/** One reported ambiguity in a `history/projects/` pattern. */
|
|
42
|
+
export interface EncodingAmbiguity {
|
|
43
|
+
readonly fragment: string;
|
|
44
|
+
readonly encoded: string;
|
|
45
|
+
readonly reason: EncodingAmbiguityReason;
|
|
46
|
+
readonly detail: string;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Reports, for a set of `history/projects/` path fragments, every case where the encoded form could plausibly correspond to more than one real path.
|
|
50
|
+
*
|
|
51
|
+
* Two signals, both actionable:
|
|
52
|
+
*
|
|
53
|
+
* - `lossy-characters` — the fragment's literal portion contains a character besides `/` that encodes to `-`. Because encoding is many-to-one, a sibling path with that character replaced by a separator (or vice versa) produces the identical name, so this pattern may match a directory belonging to a different real path.
|
|
54
|
+
* - `collides-with-sibling-pattern` — two distinct fragments in the same configuration encode identically, which proves a collision outright rather than merely suggesting one.
|
|
55
|
+
*
|
|
56
|
+
* When `existingNames` is supplied, `detail` also names how many real project directories the encoded form currently matches, so the report says what actually happened rather than only what could.
|
|
57
|
+
*/
|
|
58
|
+
export declare function detectEncodingAmbiguity(fragments: readonly string[], options: {
|
|
59
|
+
home: string;
|
|
60
|
+
existingNames?: readonly string[];
|
|
61
|
+
}): EncodingAmbiguity[];
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Diagnostic } from "./types";
|
|
2
|
+
/** The manifest the farm builder writes into each scratch tree before the atomic swap. */
|
|
3
|
+
export interface FarmManifest {
|
|
4
|
+
readonly version: 1;
|
|
5
|
+
readonly builtAtMs: number;
|
|
6
|
+
readonly identity: string;
|
|
7
|
+
readonly configProfile?: string;
|
|
8
|
+
readonly cwd: string;
|
|
9
|
+
readonly claudeHome: string;
|
|
10
|
+
/** Directories the previous resync built itself. Without this list, a directory the farm materialised and a directory Claude Code created are indistinguishable on disk — both are real, both contain real children. */
|
|
11
|
+
readonly materialised: readonly string[];
|
|
12
|
+
readonly links: readonly {
|
|
13
|
+
readonly rel: string;
|
|
14
|
+
readonly target: string;
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
/** One node of a directory listing, relative to the tree's own root. */
|
|
18
|
+
export interface ListingEntry {
|
|
19
|
+
/** Path relative to the tree root, forward-slash separated. */
|
|
20
|
+
readonly rel: string;
|
|
21
|
+
readonly kind: "dir" | "file" | "symlink";
|
|
22
|
+
/** A content hash for a file, used to recognise data that has already been adopted. Absent for directories and symlinks. */
|
|
23
|
+
readonly contentHash?: string;
|
|
24
|
+
}
|
|
25
|
+
/** What to do about one path found in the old farm. */
|
|
26
|
+
export type ReconcileAction =
|
|
27
|
+
/** New data Claude Code wrote that the canonical tree does not have. Copy it to `to`. */
|
|
28
|
+
{
|
|
29
|
+
readonly kind: "adopt";
|
|
30
|
+
readonly rel: string;
|
|
31
|
+
readonly reason: "absent-from-canonical";
|
|
32
|
+
}
|
|
33
|
+
/** New data whose canonical counterpart exists but differs. The canonical copy is authoritative; the farm copy is preserved alongside it for the user to reconcile. */
|
|
34
|
+
| {
|
|
35
|
+
readonly kind: "adopt-conflict";
|
|
36
|
+
readonly rel: string;
|
|
37
|
+
}
|
|
38
|
+
/** Nothing to do, with the reason recorded so `agent-shim check` can explain a no-op. */
|
|
39
|
+
| {
|
|
40
|
+
readonly kind: "skip";
|
|
41
|
+
readonly rel: string;
|
|
42
|
+
readonly reason: SkipReason;
|
|
43
|
+
};
|
|
44
|
+
/** Why a path in the old farm needs no action. */
|
|
45
|
+
type SkipReason =
|
|
46
|
+
/** A symlink the previous resync placed. It is a view of the canonical tree, so there is nothing to write back. */
|
|
47
|
+
"is-symlink"
|
|
48
|
+
/** A directory the previous resync materialised. Not new data itself; its children are examined individually. */
|
|
49
|
+
| "tracked-materialised-dir"
|
|
50
|
+
/** Real data whose canonical counterpart already holds identical content — already adopted, possibly by an earlier run that then crashed before the swap. */
|
|
51
|
+
| "identical-content";
|
|
52
|
+
/** The result of planning a reconciliation pass. */
|
|
53
|
+
export interface ReconciliationPlan {
|
|
54
|
+
readonly actions: readonly ReconcileAction[];
|
|
55
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
56
|
+
/** True when the manifest was missing or unusable and every real directory in the farm was treated as materialised. */
|
|
57
|
+
readonly conservative: boolean;
|
|
58
|
+
}
|
|
59
|
+
/** Everything the reconciliation planner reads. It performs no I/O of its own — the launcher executes the actions it returns. */
|
|
60
|
+
export interface ReconcileParams {
|
|
61
|
+
/** The previous resync's manifest, or undefined when it is missing or failed to parse. */
|
|
62
|
+
readonly manifest?: FarmManifest;
|
|
63
|
+
/** A flat listing of the old, still-live farm. */
|
|
64
|
+
readonly farmListing: readonly ListingEntry[];
|
|
65
|
+
/** A flat listing of the canonical `~/.claude`. */
|
|
66
|
+
readonly canonicalListing: readonly ListingEntry[];
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Plans the write-through reconciliation pass that runs before each resync builds its new scratch tree.
|
|
70
|
+
*
|
|
71
|
+
* A materialised directory stops being a live view of `~/.claude` and becomes a locally-built directory of symlinks frozen at resync time. Anything Claude Code writes into it afterwards — a brand-new project subdirectory, a new session file inside an existing one — lands as a real, untracked child: invisible to every other identity, and liable to be read as stale scaffolding and pruned by a later resync. This pass finds that data and copies it back into the canonical `~/.claude`, where it belongs, before anything else happens.
|
|
72
|
+
*
|
|
73
|
+
* The manifest is what makes this possible at all. A directory the farm materialised and a directory Claude Code created look identical on disk, so without the previous resync's own record of what it built, there is no way to tell the two apart. When the manifest is missing or unreadable, the pass falls back to **conservative mode** — every real directory in the farm is treated as materialised, so every real child anywhere becomes an adopt candidate — and reports it. That is deliberately over-eager rather than a silent no-op: over-adopting copies data into the place it was always meant to live, while under-adopting loses it.
|
|
74
|
+
*
|
|
75
|
+
* The pass is deliberately independent of the *new* resolved state. A directory whose new decision is `omit`, or one collapsing back from materialised to a plain symlink, still gets reconciled first — those are precisely the cases where the data would otherwise be discarded, which is the whole reason this exists.
|
|
76
|
+
*
|
|
77
|
+
* It is also idempotent: re-running against the same old farm after a crash mid-resync produces only `skip("identical-content")` for anything the interrupted run already copied across.
|
|
78
|
+
*/
|
|
79
|
+
export declare function planReconciliation(params: ReconcileParams): ReconciliationPlan;
|
|
80
|
+
export {};
|