@adrrr/tarmac 0.1.2 → 0.2.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/README.md +10 -5
- package/dist/cli.js +20 -5
- package/dist/collect.js +17 -9
- package/dist/install.js +316 -14
- package/dist/render.js +38 -0
- package/dist/wrapper.js +19 -7
- package/package.json +9 -1
package/README.md
CHANGED
|
@@ -12,14 +12,14 @@ busy or idle, how full its context is, which model, what it has cost so far.
|
|
|
12
12
|
$ npx @adrrr/tarmac list
|
|
13
13
|
|
|
14
14
|
PROJECT STATE CTX AS OF MODEL EFFORT COST UP
|
|
15
|
-
apollo busy 28% 7m Fable 5 max $
|
|
16
|
-
mercury-dashboard busy 27% 22m ! Fable 5 max $
|
|
17
|
-
gemini idle 12% 3h ! Opus 5 high $
|
|
15
|
+
apollo busy 28% 7m Fable 5 max $41.20 8h
|
|
16
|
+
mercury-dashboard busy 27% 22m ! Fable 5 max $62.75 8h
|
|
17
|
+
gemini idle 12% 3h ! Opus 5 high $5.40 8h
|
|
18
18
|
atlas idle — fresh 8h ! Opus 5 high $0.00 8h
|
|
19
19
|
|
|
20
20
|
! 3 reading(s) marked "!" are older than the freshness threshold
|
|
21
21
|
|
|
22
|
-
4 sessions · 2 busy · $
|
|
22
|
+
4 sessions · 2 busy · $109.35
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
## Install
|
|
@@ -40,6 +40,11 @@ it prints the full plan — including the exact command that undoes it — and w
|
|
|
40
40
|
line is wrapped, not replaced: the display stays byte-identical, and `uninstall` restores
|
|
41
41
|
the original exactly.
|
|
42
42
|
|
|
43
|
+
The only things it puts under `~/.claude/` are the wrapper and the `backup.json` that undoes
|
|
44
|
+
it, neither of which changes at runtime — the snapshots the wrapper writes at every frame go
|
|
45
|
+
to `$XDG_STATE_HOME/tarmac/snapshots` (`~/.local/state/tarmac/snapshots` by default), because
|
|
46
|
+
`~/.claude` is a directory people commit.
|
|
47
|
+
|
|
43
48
|
## The dashboard
|
|
44
49
|
|
|
45
50
|
`tarmac serve` puts the same fleet in the browser — every session a row, ages that keep
|
|
@@ -98,7 +103,7 @@ deliberately not configurable, and all of it works with no configuration at all.
|
|
|
98
103
|
|---|---|---|---|---|
|
|
99
104
|
| freshness threshold | `--stale-after 90s` \| `15m` \| `2h` | `TARMAC_STALE_AFTER` | `"staleAfterMs": 90000` | `10m` |
|
|
100
105
|
| port | `--port 8080` | `TARMAC_PORT` | `"port": 8080` | `4477` |
|
|
101
|
-
| snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | `<home>/.
|
|
106
|
+
| snapshots dir (read side) | `--snapshots-dir DIR` | `TARMAC_SNAPSHOTS_DIR` | `"snapshotsDir": "DIR"` | `$XDG_STATE_HOME/tarmac/snapshots`, else `<home>/.local/state/tarmac/snapshots` |
|
|
102
107
|
|
|
103
108
|
**Flag beats environment beats config file beats default**, settled per setting. `serve`
|
|
104
109
|
opens by printing each effective value and which source it came from. Nothing is ever
|
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { parseArgs } from './args.js';
|
|
|
13
13
|
import { collectFleet } from './collect.js';
|
|
14
14
|
import { readConfigFile, resolveConfig } from './config.js';
|
|
15
15
|
import { createFleetServer, listenFleetServer } from './server.js';
|
|
16
|
-
import { install, uninstall, paths, planInstall, planUninstall } from './install.js';
|
|
16
|
+
import { install, uninstall, paths, planInstall, planUninstall, installedSnapshotsDir, wrapperIsOurs } from './install.js';
|
|
17
17
|
import { confirmTyped } from './prompt.js';
|
|
18
18
|
import { reapOrphanedTemps } from './reap.js';
|
|
19
19
|
import { renderPlan, renderSettings, renderTable, restoreMeaning, servingLine } from './render.js';
|
|
@@ -39,7 +39,8 @@ const USAGE = `tarmac — fleet observability for Claude Code
|
|
|
39
39
|
--port the dashboard's port (default: 4477 — a busy default walks up to the
|
|
40
40
|
next free port, a port named here refuses instead)
|
|
41
41
|
--snapshots-dir where the chained statusline drops its payloads
|
|
42
|
-
(default:
|
|
42
|
+
(default: $XDG_STATE_HOME/tarmac/snapshots, or
|
|
43
|
+
<home>/.local/state/tarmac/snapshots)
|
|
43
44
|
--claude-bin path to the claude CLI (default: claude)
|
|
44
45
|
|
|
45
46
|
Those three settings can also be set, in decreasing order of precedence, by the
|
|
@@ -78,6 +79,11 @@ try {
|
|
|
78
79
|
console.log(res.alreadyInstalled
|
|
79
80
|
? `install: already installed — wrapper regenerated, settings.json left alone`
|
|
80
81
|
: `install: statusLine wrapped — undo with \`${plan.undo}\``);
|
|
82
|
+
// What the plan promised, as it actually went: a frame of the OLD wrapper can land in
|
|
83
|
+
// that directory between the two, so the count is reported rather than assumed.
|
|
84
|
+
if (res.legacy !== null)
|
|
85
|
+
console.log(`install: cleared ${res.legacy.payloads} runtime payload(s) from ${res.legacy.dir} — they belong in ${res.snapshots}` +
|
|
86
|
+
(res.legacy.kept > 0 ? ` (${res.legacy.kept} file(s) kept, so the directory stays)` : ''));
|
|
81
87
|
}
|
|
82
88
|
else {
|
|
83
89
|
const { mode } = uninstall({ home });
|
|
@@ -88,11 +94,20 @@ try {
|
|
|
88
94
|
// Only the reading commands resolve settings, and only they read the config file: a
|
|
89
95
|
// typo in it must not be what stands between a user and `tarmac uninstall`.
|
|
90
96
|
const p = paths(args.home ?? os.homedir());
|
|
97
|
+
// The installed wrapper's own path, when there is one: the default is where the WRITER
|
|
98
|
+
// writes, not where a reader's environment would have put it. Recomputing it here made
|
|
99
|
+
// `XDG_STATE_HOME` in one process and not the other a silent split.
|
|
100
|
+
const frozen = installedSnapshotsDir(p);
|
|
101
|
+
if (frozen === null && wrapperIsOurs(p))
|
|
102
|
+
console.error(`tarmac: ${p.wrapper} is ours but does not say where it writes — falling back to ${p.snapshots}`);
|
|
91
103
|
const config = resolveConfig({
|
|
92
104
|
flags: { staleAfter: args.staleAfter, port: args.port, snapshotsDir: args.snapshotsDir },
|
|
93
105
|
env: process.env,
|
|
94
106
|
file: readConfigFile(p.config),
|
|
95
|
-
|
|
107
|
+
// The installed wrapper's own path, when there is one: the default is where the
|
|
108
|
+
// WRITER writes, not where a reader's environment would have put it. Recomputing it
|
|
109
|
+
// here made `XDG_STATE_HOME` in one process and not the other a silent split.
|
|
110
|
+
defaultSnapshotsDir: frozen ?? p.snapshots,
|
|
96
111
|
});
|
|
97
112
|
const snapshotsDir = config.snapshotsDir.value;
|
|
98
113
|
const staleAfterMs = config.staleAfterMs.value;
|
|
@@ -108,7 +123,7 @@ try {
|
|
|
108
123
|
if (failed > 0)
|
|
109
124
|
console.error(`tarmac: could not remove ${failed} orphaned temp file(s) under ${snapshotsDir}`);
|
|
110
125
|
const server = createFleetServer({
|
|
111
|
-
collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source }),
|
|
126
|
+
collect: () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null }),
|
|
112
127
|
});
|
|
113
128
|
// A port nobody chose is not worth failing over: this walks past a busy 4477 and says
|
|
114
129
|
// where it landed. A port that WAS chosen refuses instead, and the refusal leaves
|
|
@@ -123,7 +138,7 @@ try {
|
|
|
123
138
|
console.log(servingLine(bound));
|
|
124
139
|
}
|
|
125
140
|
else {
|
|
126
|
-
const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source });
|
|
141
|
+
const collect = () => collectFleet({ claudeBin: args.claudeBin, snapshotsDir, staleAfterMs, snapshotsDirSource: config.snapshotsDir.source, installed: frozen !== null });
|
|
127
142
|
if (args.watch) {
|
|
128
143
|
// One redraws a screen, the other is meant to be piped once. Silently letting one win
|
|
129
144
|
// is how someone ends up parsing a frame of terminal art.
|
package/dist/collect.js
CHANGED
|
@@ -3,22 +3,30 @@ import { SOURCE_PHRASE } from './config.js';
|
|
|
3
3
|
import { discoverSessions } from './discover.js';
|
|
4
4
|
import { readSnapshots } from './snapshots.js';
|
|
5
5
|
import { buildFleet } from './fleet.js';
|
|
6
|
-
export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', }) {
|
|
6
|
+
export async function collectFleet({ claudeBin, snapshotsDir, now = Date.now(), staleAfterMs, snapshotsDirSource = 'default', installed = false, }) {
|
|
7
7
|
const { sessions, health: discovery } = await discoverSessions({ claudeBin });
|
|
8
8
|
const { snapshots, dirError, unreadable, duplicates, dirMissing } = readSnapshots(snapshotsDir, { now });
|
|
9
9
|
const fleet = buildFleet({ sessions, snapshots, now, discovery, staleAfterMs });
|
|
10
10
|
// Both blind spots travel with the data: a directory we could not read and files we
|
|
11
11
|
// could not parse are OUR failures to report, not silence to render as "all clear".
|
|
12
12
|
//
|
|
13
|
-
// An absent directory is judged HERE, because this is the layer that knows who chose it
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
|
|
13
|
+
// An absent directory is judged HERE, because this is the layer that knows who chose it.
|
|
14
|
+
// A path someone typed — flag, environment, or a config file edited months ago — that is
|
|
15
|
+
// not there is a setting pointing at nothing; rendered as "not chained yet" it sends the
|
|
16
|
+
// user to run `tarmac install`, which cannot fix it: install writes where install writes.
|
|
17
|
+
//
|
|
18
|
+
// Exactly ONE case is innocent, and it is narrower than it used to be: no install here at
|
|
19
|
+
// all. Since #20 the default is the path the install FROZE into the wrapper — a directory
|
|
20
|
+
// that was chosen and created by a run that happened — so its absence means the writer and
|
|
21
|
+
// the reader have parted company, which renders as a healthy, empty fleet. That is the one
|
|
22
|
+
// failure this tool may not have.
|
|
23
|
+
fleet.health.snapshotsError = !dirMissing
|
|
24
|
+
? dirError
|
|
25
|
+
: snapshotsDirSource !== 'default'
|
|
20
26
|
? `ENOENT: ${snapshotsDir} does not exist — set by ${SOURCE_PHRASE[snapshotsDirSource]}`
|
|
21
|
-
:
|
|
27
|
+
: installed
|
|
28
|
+
? `ENOENT: ${snapshotsDir} does not exist — the installed wrapper writes there`
|
|
29
|
+
: dirError;
|
|
22
30
|
fleet.health.snapshotsUnreadable = unreadable;
|
|
23
31
|
fleet.health.snapshotsDuplicates = duplicates;
|
|
24
32
|
fleet.health.snapshotsDir = snapshotsDir;
|
package/dist/install.js
CHANGED
|
@@ -22,25 +22,72 @@ import os from 'node:os';
|
|
|
22
22
|
import path from 'node:path';
|
|
23
23
|
import { chainStatusLine, unchainStatusLine } from './settings.js';
|
|
24
24
|
import { firstWord, quoteArg } from './shell.js';
|
|
25
|
-
import { renderWrapper, TEMP_PREFIX, WRAPPER_MARKER } from './wrapper.js';
|
|
26
|
-
export function paths(home) {
|
|
25
|
+
import { renderWrapper, PRUNE_MARKER, SNAPSHOT_NAME, TEMP_PREFIX, WRAPPER_MARKER } from './wrapper.js';
|
|
26
|
+
export function paths(home, { env = process.env, realHome } = {}) {
|
|
27
27
|
const claude = path.join(home, '.claude');
|
|
28
28
|
const dir = path.join(claude, 'tarmac');
|
|
29
|
+
const stateDir = path.join(stateRoot(home, env, realHome), 'tarmac');
|
|
29
30
|
return {
|
|
30
31
|
claude,
|
|
31
32
|
settings: path.join(claude, 'settings.json'),
|
|
32
33
|
dir,
|
|
33
34
|
wrapper: path.join(dir, 'statusline.sh'),
|
|
34
35
|
backup: path.join(dir, 'backup.json'),
|
|
35
|
-
snapshots: path.join(
|
|
36
|
+
snapshots: path.join(stateDir, 'snapshots'),
|
|
37
|
+
stateDir,
|
|
38
|
+
legacySnapshots: path.join(dir, 'snapshots'),
|
|
36
39
|
config: path.join(dir, 'config.json'),
|
|
37
40
|
};
|
|
38
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* `$XDG_STATE_HOME`, or `<home>/.local/state` — the XDG default, for "state data that should
|
|
44
|
+
* persist between restarts but is not important enough for the data directory". A snapshot
|
|
45
|
+
* is exactly that: a reading of one frame, rewritten by the next.
|
|
46
|
+
*
|
|
47
|
+
* The environment is only honoured FOR THE HOME THAT EXPORTED IT, which is the same rule
|
|
48
|
+
* `commandTarget` applies to a `~` in someone's statusLine: `--home` exists to work on
|
|
49
|
+
* someone else's `.claude`, and this process's `XDG_STATE_HOME` says nothing about theirs.
|
|
50
|
+
* Read unconditionally, `tarmac install --home /home/jane` would point jane's wrapper at our
|
|
51
|
+
* state directory. (It is NOT what keeps this project's own suite out of the developer's
|
|
52
|
+
* real one — every CLI test there replaces $HOME, so both anchors coincide by construction
|
|
53
|
+
* and this guard reads true. Removing the variable is the test helper's job, and it does it.)
|
|
54
|
+
*
|
|
55
|
+
* A relative value is ignored, as the spec asks: it would resolve against the working
|
|
56
|
+
* directory, which for a status line is wherever Claude Code happened to be started.
|
|
57
|
+
*/
|
|
58
|
+
function stateRoot(home, env, realHome) {
|
|
59
|
+
const xdg = env.XDG_STATE_HOME?.trim();
|
|
60
|
+
// Short-circuited on purpose: the real home is a QUESTION ABOUT THIS VARIABLE. Asking it
|
|
61
|
+
// unconditionally — as a default parameter — made `paths()`, which never touched the
|
|
62
|
+
// environment before, able to throw on a container with no passwd entry and no $HOME.
|
|
63
|
+
if (xdg && path.isAbsolute(xdg) && sameFile(home, thisHome(realHome)))
|
|
64
|
+
return xdg;
|
|
65
|
+
return path.join(home, '.local', 'state');
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Which home this process runs under, or `null` when the system cannot say. Unanswerable
|
|
69
|
+
* reads as "this is NOT the home that exported the variable" — the safe direction: the
|
|
70
|
+
* default under the home actually being targeted, rather than a throw or someone else's
|
|
71
|
+
* directory.
|
|
72
|
+
*/
|
|
73
|
+
function thisHome(realHome) {
|
|
74
|
+
if (realHome !== undefined)
|
|
75
|
+
return realHome;
|
|
76
|
+
try {
|
|
77
|
+
return os.homedir();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
39
83
|
// Two paths name the same directory far more often than string equality admits: `/tmp` is
|
|
40
84
|
// a symlink to `/private/tmp`, `/System/Volumes/Data/Users/x` is a macOS firmlink onto
|
|
41
85
|
// `/Users/x` (same inode, and `realpath` does NOT collapse it), plus bind mounts and
|
|
42
86
|
// relative spellings. Device + inode is the only identity that holds through all of them.
|
|
43
87
|
function sameFile(a, b) {
|
|
88
|
+
// `null` is "the question could not be answered" — never an accidental match.
|
|
89
|
+
if (b === null)
|
|
90
|
+
return false;
|
|
44
91
|
try {
|
|
45
92
|
const sa = fs.statSync(a);
|
|
46
93
|
const sb = fs.statSync(b);
|
|
@@ -135,6 +182,173 @@ function writesInstead(file) {
|
|
|
135
182
|
const target = resolveWriteTarget(file);
|
|
136
183
|
return target === file ? null : target;
|
|
137
184
|
}
|
|
185
|
+
// ── the payloads left inside `.claude` by the versions that wrote them there ────────────
|
|
186
|
+
//
|
|
187
|
+
// Moving the directory is only half of #20: the machines that hit the bug already have the
|
|
188
|
+
// files, committed, and nothing would ever remove them. They are PURGED rather than moved —
|
|
189
|
+
// a snapshot is a reading of the frame that wrote it, every live session writes a fresh one
|
|
190
|
+
// within seconds, and carrying them across would import into the new directory the very
|
|
191
|
+
// files the issue is about, dated from before the move.
|
|
192
|
+
/**
|
|
193
|
+
* What is in the legacy directory: the payloads this tool wrote, and everything else.
|
|
194
|
+
*
|
|
195
|
+
* The "ours" set is the WRITER'S RULE, not merely the writer's names. The wrapper's own
|
|
196
|
+
* sweep is `-name '<sid shape>' -type f`, and both halves are the rule — `wrapper.ts` refuses
|
|
197
|
+
* a directory or a symlink wearing a session id's name, because a name is not provenance.
|
|
198
|
+
* Matching only the name here removed a symlink the shell sweep would have left. Everything
|
|
199
|
+
* else is someone's, and one of them is enough to keep the directory (`rmdir`, never a
|
|
200
|
+
* recursive remove: the same rule the unwind states, and this one runs inside a git repo).
|
|
201
|
+
*/
|
|
202
|
+
function readLegacyDir(p) {
|
|
203
|
+
// The directory the wrapper is ABOUT TO WRITE TO is never the directory we clear, however
|
|
204
|
+
// the two came to be the same path — `XDG_STATE_HOME=$HOME/.claude` is enough. Purging it
|
|
205
|
+
// would delete the payloads while announcing the very same path as their new home.
|
|
206
|
+
if (sameFile(p.snapshots, p.legacySnapshots))
|
|
207
|
+
return null;
|
|
208
|
+
const dir = p.legacySnapshots;
|
|
209
|
+
// `lstat`, not `readdir` alone: a SYMLINK here is the workaround someone will already have
|
|
210
|
+
// applied to #20 — the directory pointed at a disk outside the repo. `readdir` follows it,
|
|
211
|
+
// so an unguarded sweep would delete their snapshots at the far end and leave the link.
|
|
212
|
+
// A directory this tool did not make is not this tool's to empty.
|
|
213
|
+
try {
|
|
214
|
+
if (!fs.lstatSync(dir).isDirectory())
|
|
215
|
+
return null;
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
return null; // absent, which is the normal case from #20 on
|
|
219
|
+
}
|
|
220
|
+
let entries;
|
|
221
|
+
try {
|
|
222
|
+
entries = fs.readdirSync(dir);
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// Unreadable is not ours to fix from inside an install.
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
const ours = [];
|
|
229
|
+
let kept = 0;
|
|
230
|
+
for (const name of entries) {
|
|
231
|
+
if (isPayloadName(name) && isPlainFile(path.join(dir, name)))
|
|
232
|
+
ours.push(name);
|
|
233
|
+
else
|
|
234
|
+
kept += 1;
|
|
235
|
+
}
|
|
236
|
+
return { ours, kept };
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The three names the wrapper writes into its snapshot directory, and nothing else.
|
|
240
|
+
*
|
|
241
|
+
* `SNAPSHOT_NAME` is the PRUNER's shape (8-4-4-4-12), deliberately narrower than the ids the
|
|
242
|
+
* writer accepts (`[0-9a-zA-Z-]{8,64}` — it refuses to guess what an id may look like). One
|
|
243
|
+
* choice, made once, and it is what produces the residue this branch states: a session id
|
|
244
|
+
* wider than a UUID is written here and swept by neither the pruner nor this. Widening it
|
|
245
|
+
* would mean deleting, from inside a git repository, by a shape rather than by a signature.
|
|
246
|
+
*/
|
|
247
|
+
const isPayloadName = (name) => SNAPSHOT_NAME.test(name) || name.startsWith(TEMP_PREFIX) || name === PRUNE_MARKER;
|
|
248
|
+
/** `lstat`: the LINK's own kind decides, since `unlink` would remove the link, not its target. */
|
|
249
|
+
const isPlainFile = (file) => {
|
|
250
|
+
try {
|
|
251
|
+
return fs.lstatSync(file).isFile();
|
|
252
|
+
}
|
|
253
|
+
catch {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
/**
|
|
258
|
+
* Did an install of OURS already exist here, before this run wrote anything?
|
|
259
|
+
*
|
|
260
|
+
* `~/.claude/tarmac/snapshots` is a documented path — this project's own docs invite you to
|
|
261
|
+
* point a reader at one — and the "ours" set is a SHAPE: a UUID name, a `.tarmac-` prefix.
|
|
262
|
+
* Shape is not provenance. Without this, a FIRST install on a home tarmac had never touched
|
|
263
|
+
* deleted another writer's files, under a plan promising they would be "written again on the
|
|
264
|
+
* next frame" by a wrapper that had never written them. It is the same reasoning that spares
|
|
265
|
+
* a symlink two functions up, applied where it was missing.
|
|
266
|
+
*
|
|
267
|
+
* Three independent proofs, any one of which is enough: the statusLine already points at us
|
|
268
|
+
* (the update path), our marker is in the wrapper (an install whose settings.json was lost),
|
|
269
|
+
* or a usable backup is on disk (a wrapper someone deleted by hand). All three must be read
|
|
270
|
+
* BEFORE this run writes anything — by the time the purge runs it has written two of them.
|
|
271
|
+
*/
|
|
272
|
+
export function tarmacWasInstalledHere(p, alreadyInstalled) {
|
|
273
|
+
return alreadyInstalled || carriesWrapperMarker(p.wrapper) || isUsableBackup(readBackup(p));
|
|
274
|
+
}
|
|
275
|
+
/** Read-only, for the plan: what an install would clear, before a byte is written. */
|
|
276
|
+
export function countLegacySnapshots(p, wasInstalled) {
|
|
277
|
+
if (!wasInstalled)
|
|
278
|
+
return null;
|
|
279
|
+
const found = readLegacyDir(p);
|
|
280
|
+
return found === null ? null : { dir: p.legacySnapshots, payloads: found.ours.length, kept: found.kept };
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* …and the deed. Best effort throughout: a payload we cannot remove is counted as KEPT, not
|
|
284
|
+
* as removed — which is both honest and exactly what decides whether the directory goes.
|
|
285
|
+
*
|
|
286
|
+
* Runs LAST in an install, after the wrapper has been rewritten: until those bytes land, the
|
|
287
|
+
* OLD wrapper is still the one Claude Code calls, and still dropping a file in here at every
|
|
288
|
+
* frame. That frame cannot be locked out — and its first act is `mkdir -p`, so it can put the
|
|
289
|
+
* whole directory back between the `rmdir` and this function returning. Hence the read-back:
|
|
290
|
+
* what is reported is what is ON DISK afterwards, never what was asked for. A user told to
|
|
291
|
+
* commit a removal that had already been undone is worse served than one told it did not take.
|
|
292
|
+
*/
|
|
293
|
+
export function purgeLegacySnapshots(p, wasInstalled) {
|
|
294
|
+
if (!wasInstalled)
|
|
295
|
+
return null;
|
|
296
|
+
const found = readLegacyDir(p);
|
|
297
|
+
if (found === null)
|
|
298
|
+
return null;
|
|
299
|
+
let payloads = 0;
|
|
300
|
+
let kept = found.kept;
|
|
301
|
+
for (const name of found.ours) {
|
|
302
|
+
try {
|
|
303
|
+
fs.unlinkSync(path.join(p.legacySnapshots, name));
|
|
304
|
+
payloads += 1;
|
|
305
|
+
}
|
|
306
|
+
catch {
|
|
307
|
+
kept += 1;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
if (kept === 0) {
|
|
311
|
+
// Twice at most. The first `rmdir` can lose a race with a frame's `mkdir -p`, and what
|
|
312
|
+
// that recreates is an EMPTY directory — which the second attempt takes. A second failure
|
|
313
|
+
// means the frame also wrote a payload into it: reported, not chased, because this is an
|
|
314
|
+
// install and not a daemon. Either way what is reported is read back from disk.
|
|
315
|
+
for (let pass = 0; pass < 2; pass++) {
|
|
316
|
+
try {
|
|
317
|
+
fs.rmdirSync(p.legacySnapshots);
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
// ENOTEMPTY from a frame that landed mid-sweep — the read-back below reports it.
|
|
321
|
+
}
|
|
322
|
+
const after = readLegacyDir(p);
|
|
323
|
+
if (after === null)
|
|
324
|
+
break; // gone, which is the whole point
|
|
325
|
+
kept = after.ours.length + after.kept;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { dir: p.legacySnapshots, payloads, kept };
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* The version-controlled directory this install writes into, nearest first — `.claude` under
|
|
332
|
+
* its own repo, else a home that is one (`git init ~` is an ordinary dotfiles setup, and the
|
|
333
|
+
* hint was silent for it while the purge ran just the same). Nearest wins, so the advice is
|
|
334
|
+
* always about the repository the files are actually in.
|
|
335
|
+
*
|
|
336
|
+
* `.git` is a FILE in a worktree or a submodule — a `gitdir:` pointer — and the people who
|
|
337
|
+
* keep `.claude` in a dotfiles repo are exactly the people who use those, so this asks
|
|
338
|
+
* whether the name exists at all rather than what it is. A bare-repo setup (`yadm`, a
|
|
339
|
+
* `--git-dir` alias) has no `.git` anywhere and is not detected: stated, not papered over.
|
|
340
|
+
*
|
|
341
|
+
* Only ever used to SAY something. Nothing here reads, writes or runs git.
|
|
342
|
+
*/
|
|
343
|
+
function gitRepoOf(p, home) {
|
|
344
|
+
const dir = [p.claude, home].find((d) => fs.existsSync(path.join(d, '.git')));
|
|
345
|
+
if (dir === undefined)
|
|
346
|
+
return null;
|
|
347
|
+
// Relative to the repository that will carry the `.gitignore`, with a trailing slash so it
|
|
348
|
+
// names a directory. A fixed `tarmac/snapshots/` was right for `.claude` and INERT for a
|
|
349
|
+
// home — `git check-ignore` says so, and a test now asks it rather than asking us.
|
|
350
|
+
return { dir, ignore: `${path.relative(dir, p.legacySnapshots)}/` };
|
|
351
|
+
}
|
|
138
352
|
export function planInstall({ home, realHome = os.homedir() }) {
|
|
139
353
|
const root = requireHome(home);
|
|
140
354
|
const p = paths(root);
|
|
@@ -158,9 +372,27 @@ export function planInstall({ home, realHome = os.homedir() }) {
|
|
|
158
372
|
after: alreadyInstalled ? before : quoteArg(p.wrapper),
|
|
159
373
|
chained: alreadyInstalled ? (backupOrRefuse(p).previous?.command ?? null) : (previous?.command ?? null),
|
|
160
374
|
alreadyInstalled,
|
|
375
|
+
snapshots: p.snapshots,
|
|
376
|
+
legacy: countLegacySnapshots(p, tarmacWasInstalledHere(p, alreadyInstalled)),
|
|
377
|
+
gitRepo: gitRepoOf(p, root),
|
|
378
|
+
movingFrom: movedFrom(p),
|
|
161
379
|
undo: undoCommand('uninstall', root, isRealHome),
|
|
162
380
|
};
|
|
163
381
|
}
|
|
382
|
+
/**
|
|
383
|
+
* The directory the installed wrapper writes to today, when this install is about to freeze a
|
|
384
|
+
* different one into it — `null` when nothing moves.
|
|
385
|
+
*
|
|
386
|
+
* `install` re-derives the path from ITS OWN environment, so a shell that exports
|
|
387
|
+
* `XDG_STATE_HOME` and a cron job that does not relocate the writer back and forth. The
|
|
388
|
+
* relocation itself is a separate question; a plan that changes where the telemetry lands
|
|
389
|
+
* without saying so is not, and the payloads left in the old directory are collected by
|
|
390
|
+
* nothing.
|
|
391
|
+
*/
|
|
392
|
+
function movedFrom(p) {
|
|
393
|
+
const current = installedSnapshotsDir(p);
|
|
394
|
+
return current === null || current === p.snapshots ? null : current;
|
|
395
|
+
}
|
|
164
396
|
/** The statusLine command as written, or `null` when there is none to read. */
|
|
165
397
|
function commandOf(statusLine) {
|
|
166
398
|
const command = statusLine?.command;
|
|
@@ -206,26 +438,83 @@ function commandTarget(command, home) {
|
|
|
206
438
|
return firstWord(s);
|
|
207
439
|
}
|
|
208
440
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* because
|
|
441
|
+
* The first `size` bytes of a file, or `null` when there are none to be had.
|
|
442
|
+
*
|
|
443
|
+
* `O_NONBLOCK`, because the path can come out of someone's settings.json: a FIFO with no
|
|
444
|
+
* writer or a dead network mount would otherwise hang a tool before it printed anything.
|
|
212
445
|
*/
|
|
213
|
-
function
|
|
446
|
+
function readHead(file, size) {
|
|
214
447
|
let fd;
|
|
215
448
|
try {
|
|
216
449
|
fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NONBLOCK);
|
|
217
|
-
const head = Buffer.alloc(
|
|
218
|
-
const read = fs.readSync(fd, head, 0,
|
|
219
|
-
return head.subarray(0, read).toString('utf8')
|
|
450
|
+
const head = Buffer.alloc(size);
|
|
451
|
+
const read = fs.readSync(fd, head, 0, size, 0);
|
|
452
|
+
return head.subarray(0, read).toString('utf8');
|
|
220
453
|
}
|
|
221
454
|
catch {
|
|
222
|
-
return
|
|
455
|
+
return null;
|
|
223
456
|
}
|
|
224
457
|
finally {
|
|
225
458
|
if (fd !== undefined)
|
|
226
459
|
fs.closeSync(fd);
|
|
227
460
|
}
|
|
228
461
|
}
|
|
462
|
+
/** Only install-time code asks this, never the render path. */
|
|
463
|
+
function carriesWrapperMarker(file) {
|
|
464
|
+
return readHead(file, 512)?.includes(WRAPPER_MARKER) ?? false;
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Is the file at the wrapper's path one of ours? Lets a caller tell "no install here" from
|
|
468
|
+
* "an install whose path we could not read" — two answers `installedSnapshotsDir` collapses
|
|
469
|
+
* into `null`, and only one of which is worth a word on stderr.
|
|
470
|
+
*/
|
|
471
|
+
export const wrapperIsOurs = (p) => carriesWrapperMarker(p.wrapper);
|
|
472
|
+
/**
|
|
473
|
+
* Where the INSTALLED wrapper actually writes — read out of the wrapper itself.
|
|
474
|
+
*
|
|
475
|
+
* The wrapper carries an absolute path, frozen into the file at install time. `list` and
|
|
476
|
+
* `serve` used to RECOMPUTE the default instead, from their own `process.env` — so an
|
|
477
|
+
* `XDG_STATE_HOME` set in an interactive shell and absent from a LaunchAgent, a systemd user
|
|
478
|
+
* unit, cron or `sudo` without `-E` had the writer filing into A while the reader watched B.
|
|
479
|
+
*
|
|
480
|
+
* That split was silent by construction: a default directory that does not exist is the
|
|
481
|
+
* zero-config case, so `collect.ts` says nothing about it, and the only symptom was
|
|
482
|
+
* `statusline chained on 0/N sessions` — which the manual itself calls "a true statement
|
|
483
|
+
* about the wrong directory". A fleet monitor whose failure looks like a healthy empty fleet
|
|
484
|
+
* is the one failure it may not have.
|
|
485
|
+
*
|
|
486
|
+
* Reading our own generated file is not parsing an internal format: this is the file this
|
|
487
|
+
* module writes, and the marker is the same one `carriesWrapperMarker` trusts everywhere
|
|
488
|
+
* else. Nothing new is stored, and an install left by an older version is picked up as it
|
|
489
|
+
* stands. `null` means "no install here to ask" — the caller then computes the default.
|
|
490
|
+
*/
|
|
491
|
+
export function installedSnapshotsDir(p) {
|
|
492
|
+
const head = readHead(p.wrapper, 8192);
|
|
493
|
+
if (head === null || !head.includes(WRAPPER_MARKER))
|
|
494
|
+
return null;
|
|
495
|
+
const line = /^TARMAC_DIR=(.*)$/m.exec(head);
|
|
496
|
+
return line === null ? null : shUnquote(line[1]);
|
|
497
|
+
}
|
|
498
|
+
/**
|
|
499
|
+
* The inverse of the single-quoting `renderWrapper` applies (`shQuote`, in `wrapper.ts`) —
|
|
500
|
+
* the exact one, never a guess.
|
|
501
|
+
*
|
|
502
|
+
* Double quotes are accepted too. Nothing here emits them, but a hand-edited wrapper, or one
|
|
503
|
+
* written by another generation of this file, exists; refusing a spelling every shell reads
|
|
504
|
+
* the same way would send the reader back to guessing from its own environment, which is the
|
|
505
|
+
* bug this function closes.
|
|
506
|
+
*
|
|
507
|
+
* An EMPTY value is not a path — it is a wrapper that writes nowhere. `args.ts` refuses
|
|
508
|
+
* `--snapshots-dir=` and `config.ts` refuses `"snapshotsDir": ""` for exactly that reason.
|
|
509
|
+
*/
|
|
510
|
+
function shUnquote(text) {
|
|
511
|
+
const s = text.trim();
|
|
512
|
+
const quote = s.startsWith("'") ? "'" : s.startsWith('"') ? '"' : null;
|
|
513
|
+
if (quote === null || s.length < 2 || !s.endsWith(quote))
|
|
514
|
+
return null;
|
|
515
|
+
const body = quote === "'" ? s.slice(1, -1).split(`'\\''`).join("'") : s.slice(1, -1);
|
|
516
|
+
return body === '' ? null : body;
|
|
517
|
+
}
|
|
229
518
|
/**
|
|
230
519
|
* A backup we cannot trust is worse than none: it is the only record of the statusline we
|
|
231
520
|
* wrapped. `previous: null` is legitimate ("there was no statusLine"), so the discriminant
|
|
@@ -246,11 +535,16 @@ export function install({ home }) {
|
|
|
246
535
|
isSameCommand: (command, wrapper) => isWrapperCommand(command, root, wrapper),
|
|
247
536
|
commandSpelling: quoteArg(p.wrapper),
|
|
248
537
|
});
|
|
538
|
+
// Read here and nowhere later: two of its three proofs are things this function is about
|
|
539
|
+
// to write, so asking afterwards would always answer yes.
|
|
540
|
+
const wasInstalled = tarmacWasInstalledHere(p, alreadyInstalled);
|
|
249
541
|
if (alreadyInstalled) {
|
|
250
542
|
const backup = backupOrRefuse(p);
|
|
251
543
|
fs.mkdirSync(p.snapshots, { recursive: true });
|
|
252
544
|
writeWrapper(p, backup.previous?.command ?? null, root);
|
|
253
|
-
|
|
545
|
+
// After the wrapper, always: this is the update path, and until that write lands the
|
|
546
|
+
// frames are still filing into the directory being cleared.
|
|
547
|
+
return { alreadyInstalled: true, previous: backup.previous ?? null, legacy: purgeLegacySnapshots(p, wasInstalled), ...p };
|
|
254
548
|
}
|
|
255
549
|
// The other end of that order: everything from here CREATES, and the settings write is
|
|
256
550
|
// the step that can still throw — a symlinked settings.json whose target lives in a
|
|
@@ -258,7 +552,7 @@ export function install({ home }) {
|
|
|
258
552
|
// Left behind, the wrapper and the backup describe an install that never happened, and
|
|
259
553
|
// that is precisely the state `uninstall` calls `foreign` and clears nothing of. So:
|
|
260
554
|
// remember what was already on disk, and unwind exactly what this run added.
|
|
261
|
-
const before = whatIsThere([p.dir, p.snapshots, p.wrapper, p.backup]);
|
|
555
|
+
const before = whatIsThere([p.dir, p.stateDir, p.snapshots, p.wrapper, p.backup]);
|
|
262
556
|
try {
|
|
263
557
|
fs.mkdirSync(p.dir, { recursive: true });
|
|
264
558
|
fs.mkdirSync(p.snapshots, { recursive: true });
|
|
@@ -274,7 +568,7 @@ export function install({ home }) {
|
|
|
274
568
|
unwind(p, before);
|
|
275
569
|
throw failure;
|
|
276
570
|
}
|
|
277
|
-
return { alreadyInstalled: false, previous, ...p };
|
|
571
|
+
return { alreadyInstalled: false, previous, legacy: purgeLegacySnapshots(p, wasInstalled), ...p };
|
|
278
572
|
}
|
|
279
573
|
/**
|
|
280
574
|
* What is on disk before we touch it: the paths that are already there, mapped to the bytes
|
|
@@ -345,7 +639,12 @@ function unwind(p, before) {
|
|
|
345
639
|
drop(p.wrapper, (f) => fs.rmSync(f, { force: true }));
|
|
346
640
|
// `rmdir`, not a recursive remove: a directory that has gained snapshots or a config since
|
|
347
641
|
// we made it holds someone else's data now, and ENOTEMPTY is the answer we want.
|
|
642
|
+
//
|
|
643
|
+
// The state directory goes back too, deepest first — `mkdir -p` made both rungs. What is
|
|
644
|
+
// ABOVE it (`~/.local/state`) is XDG's, not ours: we may have created it on a home that
|
|
645
|
+
// had none, and unmaking it would be reaching past what this tool owns.
|
|
348
646
|
drop(p.snapshots, (d) => fs.rmdirSync(d));
|
|
647
|
+
drop(p.stateDir, (d) => fs.rmdirSync(d));
|
|
349
648
|
drop(p.dir, (d) => fs.rmdirSync(d));
|
|
350
649
|
}
|
|
351
650
|
/** Never let the wrapper chain to itself, whatever spelling the caller used. */
|
|
@@ -432,6 +731,9 @@ export function planUninstall({ home, realHome = os.homedir() }) {
|
|
|
432
731
|
before: commandOf(current.statusLine),
|
|
433
732
|
after,
|
|
434
733
|
mode,
|
|
734
|
+
// Where they REALLY are: `uninstall` leaves them behind, so the path it prints has to be
|
|
735
|
+
// the wrapper's own, not one recomputed from this shell's environment.
|
|
736
|
+
snapshots: installedSnapshotsDir(p) ?? p.snapshots,
|
|
435
737
|
undo: undoCommand('install', root, isRealHome),
|
|
436
738
|
};
|
|
437
739
|
}
|
package/dist/render.js
CHANGED
|
@@ -27,9 +27,34 @@ export function renderPlan(plan) {
|
|
|
27
27
|
rows.push(['↳ which calls', `${plan.chained} (your display is unchanged)`]);
|
|
28
28
|
if (plan.alreadyInstalled)
|
|
29
29
|
rows.push(['note', 'already installed — the wrapper is regenerated, settings.json is left alone']);
|
|
30
|
+
// The snapshots directory is no longer under `.claude`, so it is no longer guessable
|
|
31
|
+
// from the path above it: naming it here is how a reader of `list`, `serve` or any other
|
|
32
|
+
// tool finds out where the payloads land.
|
|
33
|
+
rows.push(['snapshots', plan.snapshots]);
|
|
34
|
+
// A relocation is a change to where the telemetry lands, so it is never implied.
|
|
35
|
+
if (plan.movingFrom !== null)
|
|
36
|
+
rows.push(['↳ moving from', `${plan.movingFrom} (its payloads are left there, and nothing collects them)`]);
|
|
37
|
+
// This operation now DELETES files, inside a directory people commit. A plan that can
|
|
38
|
+
// disagree with what runs is worse than no plan — so it says how many, and where.
|
|
39
|
+
// A directory that is THERE but holds none of our payloads is the state the previous
|
|
40
|
+
// install left behind: nothing to announce, and nothing to ask anyone to commit.
|
|
41
|
+
const clearing = plan.legacy !== null && plan.legacy.payloads > 0;
|
|
42
|
+
if (clearing) {
|
|
43
|
+
rows.push([
|
|
44
|
+
'↳ clearing',
|
|
45
|
+
`${plan.legacy.payloads} runtime payload(s) under ${plan.legacy.dir} — each one is written again on the next frame`,
|
|
46
|
+
]);
|
|
47
|
+
if (plan.legacy.kept > 0)
|
|
48
|
+
rows.push(['↳ keeping', `${plan.legacy.kept} file(s) nothing here wrote, so that directory stays`]);
|
|
49
|
+
}
|
|
50
|
+
if (plan.gitRepo !== null)
|
|
51
|
+
rows.push(['git', gitHint(plan.gitRepo, clearing)]);
|
|
30
52
|
}
|
|
31
53
|
else {
|
|
32
54
|
rows.push(['restore', `${plan.mode} — ${restoreMeaning(plan.mode)}`]);
|
|
55
|
+
// "Your snapshots survive uninstall" is a promise about a directory nobody can guess any
|
|
56
|
+
// more, so the command that leaves them behind says where it leaves them.
|
|
57
|
+
rows.push(['snapshots', `${plan.snapshots} (left exactly as they are)`]);
|
|
33
58
|
}
|
|
34
59
|
rows.push(['undo', plan.undo]);
|
|
35
60
|
const w = Math.max(...rows.map(([label]) => label.length));
|
|
@@ -37,6 +62,19 @@ export function renderPlan(plan) {
|
|
|
37
62
|
rows.map(([label, value]) => ` ${label.padEnd(w)} ${value}\n`).join('') +
|
|
38
63
|
'\n');
|
|
39
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* The line #20 asked for, said once, to the only people it concerns: those whose `.claude`
|
|
67
|
+
* is a git repository.
|
|
68
|
+
*
|
|
69
|
+
* It has two jobs, and which one is live depends on whether the payloads are still there:
|
|
70
|
+
* an install that clears them produces a DELETION the user has to commit, and a `.gitignore`
|
|
71
|
+
* line keeps them from coming back if that directory is ever pointed at again. With nothing
|
|
72
|
+
* to clear, the only thing left to say is that this install adds nothing that churns.
|
|
73
|
+
*/
|
|
74
|
+
const gitHint = (repo, hasLegacy) => `${repo.dir} is a git repository — ` +
|
|
75
|
+
(hasLegacy
|
|
76
|
+
? `commit the removal above, and add \`${repo.ignore}\` to its .gitignore`
|
|
77
|
+
: 'nothing tarmac writes there changes at runtime; the snapshots live outside it');
|
|
40
78
|
/** What each restore mode means, in the words the plan and the report both use. */
|
|
41
79
|
export const restoreMeaning = (mode) => RESTORE_MEANING[mode];
|
|
42
80
|
const RESTORE_MEANING = {
|
package/dist/wrapper.js
CHANGED
|
@@ -54,6 +54,22 @@ export const PRUNE_EVERY_MIN = 60;
|
|
|
54
54
|
* so nothing was reading that file as current anyway.
|
|
55
55
|
*/
|
|
56
56
|
export const SNAPSHOT_TTL_MIN = 48 * 60;
|
|
57
|
+
/**
|
|
58
|
+
* The names the sweep below is allowed to remove: the 8-4-4-4-12 session id Claude Code
|
|
59
|
+
* emits, which is every fixture here and every file the live fleet directory holds. NOT
|
|
60
|
+
* `*.json` — that would take a `settings.json` or a `fleet.json` sitting next to them, data
|
|
61
|
+
* this script never wrote, deleted from inside a status line.
|
|
62
|
+
*
|
|
63
|
+
* The wrapper WRITES ids wider than this on purpose (it refuses to guess what an id may look
|
|
64
|
+
* like), so such a file is written and never pruned. That is the only direction this trade
|
|
65
|
+
* may fail in.
|
|
66
|
+
*/
|
|
67
|
+
export const SNAPSHOT_GLOB = '????????-????-????-????-????????????.json';
|
|
68
|
+
/**
|
|
69
|
+
* The same set, in Node — derived from the glob itself so the shell that deletes and the
|
|
70
|
+
* TypeScript that deletes can never drift apart. `?` is any single character, `.` is literal.
|
|
71
|
+
*/
|
|
72
|
+
export const SNAPSHOT_NAME = new RegExp(`^${SNAPSHOT_GLOB.replace(/\./g, '\\.').replace(/\?/g, '.')}$`);
|
|
57
73
|
/** Single-quotes a string for POSIX sh. */
|
|
58
74
|
function shQuote(s) {
|
|
59
75
|
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
@@ -171,13 +187,9 @@ if [ -d "$TARMAC_DIR" ]; then
|
|
|
171
187
|
# keeps a directory or a symlink wearing a session id's name out of it.
|
|
172
188
|
#
|
|
173
189
|
# The glob is the sid SHAPE, not \`*.json\`, and that is the same rule \`reap.ts\` states
|
|
174
|
-
# for the temp files: only what we wrote
|
|
175
|
-
#
|
|
176
|
-
|
|
177
|
-
# fleet directory). The wrapper accepts wider ids than that because it refuses to guess
|
|
178
|
-
# what an id may look like; a sid outside this shape is therefore written and never
|
|
179
|
-
# pruned, which is the direction this trade has to fail in.
|
|
180
|
-
find "$TARMAC_DIR"/. ! -name . -prune -name '????????-????-????-????-????????????.json' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
|
|
190
|
+
# for the temp files: only what we wrote — see SNAPSHOT_GLOB, which the legacy purge in
|
|
191
|
+
# \`install.ts\` reads from the same constant.
|
|
192
|
+
find "$TARMAC_DIR"/. ! -name . -prune -name '${SNAPSHOT_GLOB}' -type f -mmin +${SNAPSHOT_TTL_MIN} -exec rm -f {} + 2>/dev/null
|
|
181
193
|
fi
|
|
182
194
|
fi
|
|
183
195
|
fi
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adrrr/tarmac",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Fleet observability for Claude Code — reads documented surfaces only, never an internal format",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
],
|
|
15
15
|
"license": "MIT",
|
|
16
16
|
"author": "Adrien Leboeuf",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/adrrr/tarmac.git"
|
|
20
|
+
},
|
|
21
|
+
"homepage": "https://github.com/adrrr/tarmac#readme",
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/adrrr/tarmac/issues"
|
|
24
|
+
},
|
|
17
25
|
"type": "module",
|
|
18
26
|
"engines": {
|
|
19
27
|
"node": ">=20"
|