@adrrr/tarmac 0.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/dist/config.js ADDED
@@ -0,0 +1,184 @@
1
+ // P4 — configuration. Three numbers in this tool are opinions, not truths: how old a reading
2
+ // may be before it is marked stale, which port the dashboard binds, and where the snapshots
3
+ // are read from. This module is where they get their values, and — just as important — where
4
+ // they remember who gave it to them.
5
+ //
6
+ // Two rules hold everything else up:
7
+ // 1. NOTHING IS SILENT. A value we cannot parse, a key we do not know, a file that is not
8
+ // JSON — each one stops the run and says which knob to go and turn. A dropped setting is
9
+ // a lie about what the tool is doing.
10
+ // 2. THE DEFAULTS DO NOT MOVE. With no flag, no environment and no file, every number here
11
+ // is the one that was baked into the source before this module existed.
12
+ //
13
+ // Pure: no filesystem outside `readConfigFile`, no imports from the rest of the project, so
14
+ // the precedence rules can be exercised without a fleet, a home, or a process.
15
+ import fs from 'node:fs';
16
+ /** How to name a source in a sentence, when an error has to say who chose the value. */
17
+ export const SOURCE_PHRASE = {
18
+ flag: 'a command-line flag',
19
+ env: 'the environment',
20
+ file: 'the config file',
21
+ default: 'the default',
22
+ };
23
+ const UNITS = { ms: 1, s: 1000, m: 60_000, h: 3600_000 };
24
+ const DURATION = /^(\d+(?:\.\d+)?)(ms|s|m|h)$/;
25
+ /**
26
+ * A human duration — `90s`, `15m`, `2h` — in milliseconds.
27
+ *
28
+ * A bare number is refused on purpose: `600000` is ten minutes in milliseconds and a week in
29
+ * seconds, and choosing for the user is exactly the silent correction this module forbids.
30
+ *
31
+ * @param label how the source spells this setting, so the refusal names the knob to turn
32
+ */
33
+ export function parseDuration(text, label) {
34
+ const m = DURATION.exec(text.trim());
35
+ const ms = m ? Math.round(Number(m[1]) * UNITS[m[2]]) : NaN;
36
+ // Rounded BEFORE the guard: `0.4ms` clears a `> 0` test and then lands on 0, which marks
37
+ // every reading stale and explains itself with `older than 0h` — a spelling this very
38
+ // parser refuses to read back.
39
+ if (!Number.isFinite(ms) || ms <= 0) {
40
+ throw new Error(`${label} must be a positive duration like 90s, 15m or 2h, got: ${text}`);
41
+ }
42
+ return ms;
43
+ }
44
+ /**
45
+ * A duration back into the units it would have been typed in — the largest one that divides
46
+ * it evenly, so `600000` reads as `10m`. Printed next to every `!`: a threshold nobody can
47
+ * see is a mark nobody can argue with. Round-trips through `parseDuration`.
48
+ */
49
+ export function formatDuration(ms) {
50
+ for (const unit of ['h', 'm', 's']) {
51
+ if (ms % UNITS[unit] === 0)
52
+ return `${ms / UNITS[unit]}${unit}`;
53
+ }
54
+ return `${ms}ms`;
55
+ }
56
+ /** A TCP port from text. `0` stays legal — it means "pick a free one", which `serve` uses. */
57
+ export function parsePort(text, label) {
58
+ const trimmed = text.trim();
59
+ const n = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
60
+ return checkPort(n, label, text);
61
+ }
62
+ function checkPort(n, label, shown = n) {
63
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 65535) {
64
+ throw new Error(`${label} must be a port number between 0 and 65535, got: ${shown}`);
65
+ }
66
+ return n;
67
+ }
68
+ const KNOWN_KEYS = ['staleAfterMs', 'port', 'snapshotsDir'];
69
+ /**
70
+ * `~/.claude/tarmac/config.json`, if there is one.
71
+ *
72
+ * ABSENT IS SILENCE, UNREADABLE IS NOT. No file at all is the zero-config contract and
73
+ * returns `{}`. A file that exists and cannot be read, parsed, or understood stops the run:
74
+ * a settings file that turns out to have been ignored all along is worse than no file.
75
+ */
76
+ export function readConfigFile(file) {
77
+ let text;
78
+ try {
79
+ text = fs.readFileSync(file, 'utf8');
80
+ }
81
+ catch (e) {
82
+ if (e.code === 'ENOENT')
83
+ return {};
84
+ throw new Error(`could not read ${file}: ${e.message}`);
85
+ }
86
+ let raw;
87
+ try {
88
+ raw = JSON.parse(text);
89
+ }
90
+ catch (e) {
91
+ throw new Error(`${file} is not valid JSON: ${e.message}`);
92
+ }
93
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
94
+ throw new Error(`${file} must contain a JSON object with keys ${KNOWN_KEYS.join(', ')}`);
95
+ }
96
+ const body = raw;
97
+ const unknown = Object.keys(body).filter((k) => !KNOWN_KEYS.includes(k));
98
+ if (unknown.length > 0) {
99
+ throw new Error(`unknown key(s) in ${file}: ${unknown.join(', ')} — known keys are ${KNOWN_KEYS.join(', ')}`);
100
+ }
101
+ const out = {};
102
+ const where = (key) => `${file}: ${key}`;
103
+ if ('staleAfterMs' in body) {
104
+ const v = body.staleAfterMs;
105
+ // Rounded before the guard, same reason as `parseDuration`: 0.4 ms is not a threshold.
106
+ const ms = typeof v === 'number' && Number.isFinite(v) ? Math.round(v) : NaN;
107
+ if (!Number.isFinite(ms) || ms <= 0) {
108
+ throw new Error(`${where('staleAfterMs')} must be a positive number of milliseconds, got: ${format(v)}`);
109
+ }
110
+ out.staleAfterMs = ms;
111
+ }
112
+ if ('port' in body)
113
+ out.port = checkPort(body.port, where('port'), format(body.port));
114
+ if ('snapshotsDir' in body) {
115
+ const v = body.snapshotsDir;
116
+ if (typeof v !== 'string' || v.trim() === '') {
117
+ throw new Error(`${where('snapshotsDir')} must be a non-empty path, got: ${format(v)}`);
118
+ }
119
+ out.snapshotsDir = v;
120
+ }
121
+ return out;
122
+ }
123
+ /**
124
+ * Values come back from JSON as anything; the refusal has to be able to show them all —
125
+ * including the empty string, which as bare text turns `got: ` into a message that reads
126
+ * like the message itself is broken.
127
+ */
128
+ const format = (v) => v === '' ? '(empty)' : typeof v === 'string' ? v : JSON.stringify(v) ?? String(v);
129
+ // ── resolution ──────────────────────────────────────────────────────────────────────────
130
+ /**
131
+ * A snapshot is written at every TUI frame, so its age is the age of the READING. An idle
132
+ * session redraws rarely: its number is not wrong, but it is "as of" hours ago, and must not
133
+ * render identically to one measured a minute ago. Threshold, not truth — hence settable.
134
+ */
135
+ export const DEFAULT_STALE_AFTER_MS = 10 * 60_000;
136
+ export const DEFAULT_PORT = 4477;
137
+ /**
138
+ * Flag beats environment beats file beats default, settled INDEPENDENTLY per setting: a port
139
+ * pinned in the file and a threshold tightened for one run is the normal case, not an edge.
140
+ */
141
+ export function resolveConfig({ flags, env, file, defaultSnapshotsDir }) {
142
+ // EVERY rung is parsed, including the ones about to lose. `readConfigFile` already refuses
143
+ // a bad key whoever wins; an environment that were only checked when it happens to win
144
+ // would make a stale TARMAC_STALE_AFTER in a shell profile break `tarmac list` on its own
145
+ // and pass the moment a flag is added — a setting silently dropped, which is the one thing
146
+ // this module exists to prevent.
147
+ const staleAfterEnv = parseIfSet(env.TARMAC_STALE_AFTER, (v) => parseDuration(v, 'TARMAC_STALE_AFTER'));
148
+ const portEnv = parseIfSet(env.TARMAC_PORT, (v) => parsePort(v, 'TARMAC_PORT'));
149
+ const dirEnv = read(env.TARMAC_SNAPSHOTS_DIR);
150
+ return {
151
+ staleAfterMs: flags.staleAfter !== null
152
+ ? { value: parseDuration(flags.staleAfter, '--stale-after'), source: 'flag' }
153
+ : staleAfterEnv !== null
154
+ ? { value: staleAfterEnv, source: 'env' }
155
+ : file.staleAfterMs !== undefined
156
+ ? { value: file.staleAfterMs, source: 'file' }
157
+ : { value: DEFAULT_STALE_AFTER_MS, source: 'default' },
158
+ port: flags.port !== null
159
+ ? { value: flags.port, source: 'flag' }
160
+ : portEnv !== null
161
+ ? { value: portEnv, source: 'env' }
162
+ : file.port !== undefined
163
+ ? { value: file.port, source: 'file' }
164
+ : { value: DEFAULT_PORT, source: 'default' },
165
+ snapshotsDir: flags.snapshotsDir !== null
166
+ ? { value: flags.snapshotsDir, source: 'flag' }
167
+ : dirEnv !== null
168
+ ? { value: dirEnv, source: 'env' }
169
+ : file.snapshotsDir !== undefined
170
+ ? { value: file.snapshotsDir, source: 'file' }
171
+ : { value: defaultSnapshotsDir, source: 'default' },
172
+ };
173
+ }
174
+ /**
175
+ * An empty environment variable is UNSET, not an empty value: `TARMAC_PORT= tarmac serve` is
176
+ * how a shell wrapper says "never mind", and refusing it there would break scripts that clear
177
+ * their own environment. The only place in this module where absence is inferred.
178
+ */
179
+ const read = (v) => (v === undefined || v.trim() === '' ? null : v);
180
+ /** Parse a variable that is set — win or lose — so a bad value is never carried in silence. */
181
+ function parseIfSet(raw, parse) {
182
+ const v = read(raw);
183
+ return v === null ? null : parse(v);
184
+ }
@@ -0,0 +1,26 @@
1
+ // P1 — runs the contractual discovery command and hands its stdout to the parser.
2
+ //
3
+ // Fails loudly on every failure mode (binary missing, non-zero exit, unparseable output).
4
+ // An observability tool that answers "0 sessions" when it simply could not look is worse
5
+ // than one that answers nothing.
6
+ import { execFile } from 'node:child_process';
7
+ import { parseAgents } from './sessions.js';
8
+ export function discoverSessions({ claudeBin = 'claude', timeoutMs = 15000 } = {}) {
9
+ return new Promise((resolve, reject) => {
10
+ execFile(claudeBin, ['agents', '--json'], { timeout: timeoutMs, maxBuffer: 8 << 20 }, (err, stdout) => {
11
+ if (err) {
12
+ const why = err.code === 'ENOENT'
13
+ ? `${claudeBin}: not found`
14
+ : `${claudeBin} agents --json: exited ${err.code ?? '?'}${err.signal ? ` (${err.signal})` : ''}`;
15
+ reject(new Error(why));
16
+ return;
17
+ }
18
+ try {
19
+ resolve(parseAgents(stdout));
20
+ }
21
+ catch (e) {
22
+ reject(e);
23
+ }
24
+ });
25
+ });
26
+ }
package/dist/fleet.js ADDED
@@ -0,0 +1,85 @@
1
+ // P3 — the join. Two contractual sources, one row per live session.
2
+ //
3
+ // `claude agents --json` → who exists, and whether it is busy (the spine)
4
+ // statusline snapshots → context, model, effort, cost (the flesh)
5
+ //
6
+ // The session list is the spine on purpose: a snapshot outlives its session (a recycled
7
+ // fleet leaves seven dead files behind), so a snapshot with no live session is a ghost and
8
+ // is dropped. The reverse — a session with no snapshot — is a real state and stays visible,
9
+ // with telemetry marked `absent` rather than zeroed.
10
+ //
11
+ // `now` is a parameter, never `Date.now()` inside: a suite whose fixtures are absolute and
12
+ // whose clock floats dies quietly the day it crosses a threshold.
13
+ import path from 'node:path';
14
+ import { DEFAULT_STALE_AFTER_MS } from './config.js';
15
+ import { guardVersions } from './schema.js';
16
+ export function buildFleet({ sessions, snapshots, now, staleAfterMs = DEFAULT_STALE_AFTER_MS, discovery = null, }) {
17
+ const rows = sessions.map((s) => {
18
+ const t = (s.sessionId && snapshots.get(s.sessionId)) || null;
19
+ return {
20
+ sessionId: s.sessionId,
21
+ name: s.name,
22
+ project: s.cwd ? path.basename(s.cwd) : null,
23
+ cwd: s.cwd,
24
+ pid: s.pid,
25
+ status: s.status,
26
+ busy: s.busy,
27
+ uptimeMs: typeof s.startedAt === 'number' ? now - s.startedAt : null,
28
+ ctxState: t ? t.ctxState : 'absent',
29
+ ctxPct: t ? t.ctxPct : null,
30
+ ctxTokens: t ? t.ctxTokens : null,
31
+ ctxWindow: t ? t.ctxWindow : null,
32
+ model: t ? t.model : null,
33
+ effort: t ? t.effort : null,
34
+ costUsd: t ? t.costUsd : null,
35
+ snapshotAgeMs: t ? t.ageMs : null,
36
+ stale: t ? t.ageMs > staleAfterMs : false,
37
+ rateLimits: t ? t.rateLimits : null,
38
+ };
39
+ });
40
+ rows.sort((a, b) => rank(a) - rank(b) || (b.ctxPct ?? -1) - (a.ctxPct ?? -1));
41
+ const covered = rows.filter((r) => r.ctxState !== 'absent').length;
42
+ const drift = rows.filter((r) => r.ctxState === 'drift').length;
43
+ // Having a snapshot and having a cost are different facts, and only the second one is
44
+ // allowed to feed the total.
45
+ const costs = rows.map((r) => r.costUsd).filter((c) => typeof c === 'number');
46
+ // Versions from the LIVE snapshots only — the same `sessions`-is-the-spine rule as
47
+ // everything else here. A recycled fleet leaves dead files behind, and a dead session's
48
+ // Claude Code is not the one running now.
49
+ const ccVersions = sessions
50
+ .map((s) => (s.sessionId ? snapshots.get(s.sessionId) : undefined))
51
+ .filter((t) => t !== undefined)
52
+ .map((t) => t.ccVersion);
53
+ return {
54
+ rows,
55
+ health: {
56
+ sessions: rows.length,
57
+ covered,
58
+ drift,
59
+ stale: rows.filter((r) => r.stale).length,
60
+ // Discovery's own blind spots. Dropping them turns a renamed `sessionId` into the
61
+ // cheerful "No Claude Code sessions found" — the exact lie the module forbids.
62
+ discovered: discovery?.seen ?? rows.length,
63
+ noSessionId: discovery?.noSessionId ?? 0,
64
+ // All the telemetry we DO have is drifting: that is a schema change, not a hiccup.
65
+ // Tested tolerance from the fleet: `fresh` never counts, or a recycled fleet would
66
+ // raise this every single night.
67
+ schemaBroken: covered > 0 && drift === covered,
68
+ unknownStatus: rows.filter((r) => r.busy === null).length,
69
+ busy: rows.filter((r) => r.busy === true).length,
70
+ // A sum over 3 of 7 sessions is not the fleet's cost. Same rule as `sumUsage` one
71
+ // layer down: add only what is really a number, and count those — a payload with no
72
+ // `cost` key used to contribute a confident 0 while counting as covered, which made a
73
+ // partial sum print as a complete one. Null when nothing was measured; the renderers
74
+ // qualify the total with `costReporting` whenever it is partial.
75
+ costUsd: costs.length === 0 ? null : round2(costs.reduce((sum, c) => sum + c, 0)),
76
+ costReporting: costs.length,
77
+ schemaGuard: guardVersions(ccVersions),
78
+ staleAfterMs,
79
+ generatedAt: now,
80
+ },
81
+ };
82
+ }
83
+ // busy first, then unknown (it might be busy), then idle
84
+ const rank = (r) => (r.busy === true ? 0 : r.busy === null ? 1 : 2);
85
+ const round2 = (n) => Math.round(n * 100) / 100;
@@ -0,0 +1,387 @@
1
+ // P2 — the installer, on disk.
2
+ //
3
+ // CONSENT, NOT A GUARD: the spike refused the real HOME outright, which is exactly the one
4
+ // thing a released tool must be able to do. What stands in its place is `planInstall` /
5
+ // `planUninstall` — a dry run that reads only, names the file, quotes the command it will
6
+ // wrap and spells out the way back — plus the typed confirmation in `prompt.ts`. Nothing
7
+ // here decides on the user's behalf; every refusal below is about a state we cannot undo,
8
+ // never about which directory it is.
9
+ //
10
+ // The suite still never reaches the real home: `os.homedir()` reads $HOME, so the tests
11
+ // that exercise the default target run under a throwaway one.
12
+ //
13
+ // Reversibility model — two levels, because "byte for byte" and "do not clobber the user's
14
+ // later edits" cannot both hold unconditionally:
15
+ // • settings.json untouched since we wrote it → restore the ORIGINAL BYTES verbatim
16
+ // (indentation, key order, trailing newline — everything).
17
+ // • settings.json edited since → surgical restore of the statusLine key
18
+ // only, everything else the user wrote is kept (re-serialised, so formatting may move).
19
+ // Which of the two ran is reported back to the caller, never guessed at silently.
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { chainStatusLine, unchainStatusLine } from './settings.js';
24
+ import { firstWord, quoteArg } from './shell.js';
25
+ import { renderWrapper, TEMP_PREFIX, WRAPPER_MARKER } from './wrapper.js';
26
+ export function paths(home) {
27
+ const claude = path.join(home, '.claude');
28
+ const dir = path.join(claude, 'tarmac');
29
+ return {
30
+ claude,
31
+ settings: path.join(claude, 'settings.json'),
32
+ dir,
33
+ wrapper: path.join(dir, 'statusline.sh'),
34
+ backup: path.join(dir, 'backup.json'),
35
+ snapshots: path.join(dir, 'snapshots'),
36
+ config: path.join(dir, 'config.json'),
37
+ };
38
+ }
39
+ // Two paths name the same directory far more often than string equality admits: `/tmp` is
40
+ // a symlink to `/private/tmp`, `/System/Volumes/Data/Users/x` is a macOS firmlink onto
41
+ // `/Users/x` (same inode, and `realpath` does NOT collapse it), plus bind mounts and
42
+ // relative spellings. Device + inode is the only identity that holds through all of them.
43
+ function sameFile(a, b) {
44
+ try {
45
+ const sa = fs.statSync(a);
46
+ const sb = fs.statSync(b);
47
+ return sa.dev === sb.dev && sa.ino === sb.ino;
48
+ }
49
+ catch {
50
+ // one of them does not exist yet — fall back to the strongest textual comparison
51
+ return realpathOrSelf(a) === realpathOrSelf(b);
52
+ }
53
+ }
54
+ function realpathOrSelf(p) {
55
+ try {
56
+ return fs.realpathSync(p);
57
+ }
58
+ catch {
59
+ return path.resolve(p);
60
+ }
61
+ }
62
+ // A home that is not there is a typo, not an instruction. Creating `<typo>/.claude/tarmac/`
63
+ // and reporting success is how someone ends up believing tarmac watches a directory nothing
64
+ // will ever write to — the same failure `args.ts` refuses for a misspelled flag.
65
+ function requireHome(home) {
66
+ if (!home)
67
+ throw new Error('a HOME is required');
68
+ const root = path.resolve(home);
69
+ let stat;
70
+ try {
71
+ stat = fs.statSync(root);
72
+ }
73
+ catch {
74
+ throw new Error(`${root} does not exist — pass --home a directory that does`);
75
+ }
76
+ if (!stat.isDirectory())
77
+ throw new Error(`${root} is not a directory`);
78
+ return root;
79
+ }
80
+ /** @throws if settings.json exists and is not JSON — the one file we must never mangle. */
81
+ function readSettings(p) {
82
+ const text = fs.existsSync(p.settings) ? fs.readFileSync(p.settings, 'utf8') : null;
83
+ let settings = {};
84
+ if (text !== null && text.trim() !== '') {
85
+ try {
86
+ settings = JSON.parse(text);
87
+ }
88
+ catch {
89
+ throw new Error(`${p.settings} is not valid JSON — refusing to touch it`);
90
+ }
91
+ }
92
+ return { text, settings };
93
+ }
94
+ /**
95
+ * On a re-install the original statusLine lives in the backup — never in the current
96
+ * settings, which already point at us. If that backup is gone, unreadable or shapeless we
97
+ * can no longer NAME what we wrapped: regenerating the wrapper from that hole would
98
+ * silently drop the user's real statusline forever. Refuse, and touch nothing.
99
+ */
100
+ function backupOrRefuse(p) {
101
+ const backup = readBackup(p);
102
+ if (!isUsableBackup(backup)) {
103
+ throw new Error(`${p.settings} already points at the tarmac wrapper but its backup (${p.backup}) is missing, unreadable or incomplete — ` +
104
+ `refusing to regenerate the wrapper, which would drop the statusline it wraps. ` +
105
+ `Restore the backup, or point statusLine back at your own command and install again.`);
106
+ }
107
+ return backup;
108
+ }
109
+ // "The exact command that undoes it" has to survive being pasted back into a shell, and a
110
+ // home with a space in its name is an ordinary macOS home — `quoteArg` lives in `shell.ts`
111
+ // next to the reader that undoes it.
112
+ const undoCommand = (verb, home, isRealHome) => isRealHome ? `tarmac ${verb}` : `tarmac ${verb} --home ${quoteArg(home)}`;
113
+ /**
114
+ * The file a write to `file` really reaches. `realpath` cannot answer this: it fails on a
115
+ * DANGLING link — a dotfiles repo not cloned yet is exactly that — and the fallback then
116
+ * renames over the link, replacing it with a regular file. Following the links by hand
117
+ * lands where a shell's `>` would, and creates the file the link names.
118
+ */
119
+ function resolveWriteTarget(file) {
120
+ let current = file;
121
+ for (let hops = 0; hops < 10; hops++) {
122
+ let link;
123
+ try {
124
+ link = fs.readlinkSync(current);
125
+ }
126
+ catch {
127
+ return current; // not a link (or unreadable): this is the file
128
+ }
129
+ current = path.resolve(path.dirname(current), link);
130
+ }
131
+ return current; // a loop of links: stop somewhere rather than spin
132
+ }
133
+ /** …and say so, when that is not the path the plan names. */
134
+ function writesInstead(file) {
135
+ const target = resolveWriteTarget(file);
136
+ return target === file ? null : target;
137
+ }
138
+ export function planInstall({ home, realHome = os.homedir() }) {
139
+ const root = requireHome(home);
140
+ const p = paths(root);
141
+ const { settings } = readSettings(p);
142
+ const { previous, alreadyInstalled } = chainStatusLine(settings, p.wrapper, {
143
+ isSameCommand: (command, wrapper) => isWrapperCommand(command, root, wrapper),
144
+ commandSpelling: quoteArg(p.wrapper),
145
+ });
146
+ const isRealHome = sameFile(root, realHome);
147
+ const before = commandOf(settings.statusLine);
148
+ return {
149
+ action: 'install',
150
+ home: root,
151
+ settings: p.settings,
152
+ wrapper: p.wrapper,
153
+ isRealHome,
154
+ writes: writesInstead(p.settings),
155
+ before,
156
+ // A re-install regenerates the wrapper and leaves settings.json alone, so announcing a
157
+ // new value there would be a plan disagreeing with what runs.
158
+ after: alreadyInstalled ? before : quoteArg(p.wrapper),
159
+ chained: alreadyInstalled ? (backupOrRefuse(p).previous?.command ?? null) : (previous?.command ?? null),
160
+ alreadyInstalled,
161
+ undo: undoCommand('uninstall', root, isRealHome),
162
+ };
163
+ }
164
+ /** The statusLine command as written, or `null` when there is none to read. */
165
+ function commandOf(statusLine) {
166
+ const command = statusLine?.command;
167
+ return typeof command === 'string' ? command : null;
168
+ }
169
+ /**
170
+ * Is `command` the very wrapper at `wrapperPath`, under any spelling?
171
+ *
172
+ * `stat` of the raw string is not an answer: `statusLine.command` is read by a shell, and
173
+ * `~/…` — how this machine's own production statusline is written — stats to nothing. A
174
+ * tarmac that fails to recognise itself there wraps its own wrapper (unbounded recursion at
175
+ * every frame) AND rewrites the backup with the wrapper as "what we wrapped", erasing the
176
+ * only record of the user's real statusline. So: resolve the spellings a shell would, then
177
+ * ask the file itself, which carries a marker no other file has a reason to.
178
+ */
179
+ function isOurWrapperPath(command, home, wrapperPath) {
180
+ return sameFile(commandTarget(command, home), wrapperPath);
181
+ }
182
+ /**
183
+ * Deciding whether to WRAP something: path identity, or the file saying what it is. Over-
184
+ * claiming here fails closed — tarmac refuses instead of nesting a wrapper in a wrapper.
185
+ *
186
+ * Deciding whether to OVERWRITE something (uninstall) is the opposite risk, so it asks
187
+ * `isOurWrapperPath` alone: a script of the user's that merely mentions the wrapper stays
188
+ * foreign, and foreign still means "nothing is restored and nothing is deleted".
189
+ */
190
+ function isWrapperCommand(command, home, wrapperPath) {
191
+ return isOurWrapperPath(command, home, wrapperPath) || carriesWrapperMarker(commandTarget(command, home));
192
+ }
193
+ /**
194
+ * The file a statusLine command refers to.
195
+ *
196
+ * `~`, `$HOME` and `${HOME}` are resolved against `home` — the home whose settings.json this
197
+ * is — not against the process's own. `--home` exists precisely to work on someone else's
198
+ * `.claude`, and the shell that will run that line runs under THAT home.
199
+ */
200
+ function commandTarget(command, home) {
201
+ const s = command.trim();
202
+ for (const [prefix, cut] of [['~/', 2], ['$HOME/', 6], ['${HOME}/', 8]]) {
203
+ if (s.startsWith(prefix))
204
+ return path.join(home, firstWord(s.slice(cut)));
205
+ }
206
+ return firstWord(s);
207
+ }
208
+ /**
209
+ * Only install-time code asks, never the render path — but the path comes out of someone's
210
+ * settings.json, so it may be a FIFO with no writer or a dead network mount. `O_NONBLOCK`,
211
+ * because a tool that hangs before printing anything is indistinguishable from one that died.
212
+ */
213
+ function carriesWrapperMarker(file) {
214
+ let fd;
215
+ try {
216
+ fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
217
+ const head = Buffer.alloc(512);
218
+ const read = fs.readSync(fd, head, 0, head.length, 0);
219
+ return head.subarray(0, read).toString('utf8').includes(WRAPPER_MARKER);
220
+ }
221
+ catch {
222
+ return false;
223
+ }
224
+ finally {
225
+ if (fd !== undefined)
226
+ fs.closeSync(fd);
227
+ }
228
+ }
229
+ /**
230
+ * A backup we cannot trust is worse than none: it is the only record of the statusline we
231
+ * wrapped. `previous: null` is legitimate ("there was no statusLine"), so the discriminant
232
+ * is the PRESENCE of the key — the same rule this codebase applies to `used_percentage`.
233
+ */
234
+ function isUsableBackup(b) {
235
+ return (!!b &&
236
+ typeof b === 'object' &&
237
+ b.version === 1 &&
238
+ 'previous' in b &&
239
+ 'originalText' in b);
240
+ }
241
+ export function install({ home }) {
242
+ const root = requireHome(home);
243
+ const p = paths(root);
244
+ const { text: originalText, settings } = readSettings(p);
245
+ const { settings: next, previous, alreadyInstalled } = chainStatusLine(settings, p.wrapper, {
246
+ isSameCommand: (command, wrapper) => isWrapperCommand(command, root, wrapper),
247
+ commandSpelling: quoteArg(p.wrapper),
248
+ });
249
+ if (alreadyInstalled) {
250
+ const backup = backupOrRefuse(p);
251
+ fs.mkdirSync(p.snapshots, { recursive: true });
252
+ writeWrapper(p, backup.previous?.command ?? null, root);
253
+ return { alreadyInstalled: true, previous: backup.previous ?? null, ...p };
254
+ }
255
+ fs.mkdirSync(p.dir, { recursive: true });
256
+ fs.mkdirSync(p.snapshots, { recursive: true });
257
+ writeWrapper(p, previous?.command ?? null, root);
258
+ // Order matters: the backup is the only way back, so it must be on disk BEFORE
259
+ // settings.json sends Claude Code to the wrapper. Crashing between the two otherwise
260
+ // locks the user out of install (backup missing) and uninstall (no install found) at once.
261
+ const installedText = JSON.stringify(next, null, 2) + '\n';
262
+ fs.writeFileSync(p.backup, JSON.stringify({ version: 1, originalText, installedText, previous, installedAt: new Date().toISOString() }, null, 2));
263
+ writeAtomic(p.settings, installedText);
264
+ return { alreadyInstalled: false, previous, ...p };
265
+ }
266
+ /** Never let the wrapper chain to itself, whatever spelling the caller used. */
267
+ function writeWrapper(p, chainCommand, home) {
268
+ if (chainCommand && isWrapperCommand(chainCommand, home, p.wrapper)) {
269
+ throw new Error(`refusing to chain the tarmac wrapper to itself (${chainCommand})`);
270
+ }
271
+ fs.writeFileSync(p.wrapper, renderWrapper({ snapshotDir: p.snapshots, chainCommand }), { mode: 0o755 });
272
+ }
273
+ // Claude Code re-reads settings.json at frame cadence: a truncated read window is real.
274
+ //
275
+ // Two things the rename must not quietly destroy, both invisible in a diff of the contents:
276
+ // • a SYMLINK. settings.json kept in a dotfiles repo and linked into place is the normal
277
+ // setup for the people who install a tool like this; renaming over the link replaces it
278
+ // with a regular file, the dotfile silently stops being the source of truth, and no
279
+ // restore puts the link back. So the write follows the link and lands on its target.
280
+ // • the MODE. `-rw-------` is a decision; a restore that hands the file back
281
+ // world-readable is not the file the user had, whatever the bytes say.
282
+ function writeAtomic(file, text) {
283
+ const target = resolveWriteTarget(file);
284
+ const tmp = `${target}${TEMP_PREFIX}${process.pid}.tmp`;
285
+ fs.writeFileSync(tmp, text);
286
+ const mode = modeOf(target);
287
+ if (mode !== null)
288
+ fs.chmodSync(tmp, mode); // after the write: umask masks the create mode
289
+ fs.renameSync(tmp, target);
290
+ }
291
+ /** The permission bits of an existing file, or `null` when we are creating it. */
292
+ function modeOf(file) {
293
+ try {
294
+ return fs.statSync(file).mode & 0o777;
295
+ }
296
+ catch {
297
+ return null;
298
+ }
299
+ }
300
+ export function planUninstall({ home, realHome = os.homedir() }) {
301
+ const root = requireHome(home);
302
+ const p = paths(root);
303
+ const backup = installedBackupOrRefuse(p);
304
+ // Through `readSettings`, so a settings.json that stopped being JSON since install is
305
+ // named, not reported as a raw parser position nobody can act on.
306
+ const { text: currentText, settings: current } = readSettings(p);
307
+ // Predicted by asking the same two questions `uninstall` asks, in the same order. The
308
+ // surgical branch is a pure function, so the prediction runs it and throws the result away.
309
+ let mode;
310
+ let after;
311
+ if (currentText === backup.installedText) {
312
+ mode = backup.originalText === null ? 'absent' : 'bytes';
313
+ after = backup.previous?.command ?? null;
314
+ }
315
+ else {
316
+ const { settings, restored } = unchainStatusLine(current, backup.previous, p.wrapper, {
317
+ isSameCommand: (command, wrapper) => isOurWrapperPath(command, root, wrapper),
318
+ });
319
+ mode = restored ? 'surgical' : 'foreign';
320
+ after = restored ? commandOf(settings.statusLine) : commandOf(current.statusLine);
321
+ }
322
+ const isRealHome = sameFile(root, realHome);
323
+ return {
324
+ action: 'uninstall',
325
+ home: root,
326
+ settings: p.settings,
327
+ wrapper: p.wrapper,
328
+ isRealHome,
329
+ writes: writesInstead(p.settings),
330
+ before: commandOf(current.statusLine),
331
+ after,
332
+ mode,
333
+ undo: undoCommand('install', root, isRealHome),
334
+ };
335
+ }
336
+ function installedBackupOrRefuse(p) {
337
+ const backup = readBackup(p);
338
+ if (!isUsableBackup(backup))
339
+ throw new Error(`no tarmac install found under ${p.dir}`);
340
+ return backup;
341
+ }
342
+ export function uninstall({ home }) {
343
+ const root = requireHome(home);
344
+ const p = paths(root);
345
+ const backup = installedBackupOrRefuse(p);
346
+ const currentText = fs.existsSync(p.settings) ? fs.readFileSync(p.settings, 'utf8') : null;
347
+ let mode;
348
+ if (currentText === backup.installedText) {
349
+ // untouched since install → put the original bytes back, or remove the file we created
350
+ if (backup.originalText === null) {
351
+ fs.rmSync(p.settings, { force: true });
352
+ mode = 'absent';
353
+ }
354
+ else {
355
+ writeAtomic(p.settings, backup.originalText);
356
+ mode = 'bytes';
357
+ }
358
+ }
359
+ else {
360
+ const current = currentText ? JSON.parse(currentText) : {};
361
+ const { settings, restored } = unchainStatusLine(current, backup.previous, p.wrapper, {
362
+ isSameCommand: (command, wrapper) => isOurWrapperPath(command, root, wrapper),
363
+ });
364
+ if (!restored) {
365
+ // The statusLine is someone else's now — possibly still pointing AT our wrapper's
366
+ // path through another route. Deleting the wrapper here would leave that line
367
+ // executing a file that no longer exists, at every frame, with no way back.
368
+ return { mode: 'foreign' };
369
+ }
370
+ writeAtomic(p.settings, JSON.stringify(settings, null, 2) + '\n');
371
+ mode = 'surgical';
372
+ }
373
+ // Snapshots are data the user may still want; only what we generated goes.
374
+ fs.rmSync(p.wrapper, { force: true });
375
+ fs.rmSync(p.backup, { force: true });
376
+ return { mode };
377
+ }
378
+ function readBackup(p) {
379
+ if (!fs.existsSync(p.backup))
380
+ return null;
381
+ try {
382
+ return JSON.parse(fs.readFileSync(p.backup, 'utf8'));
383
+ }
384
+ catch {
385
+ return null;
386
+ }
387
+ }