@mattstack/rt-client 0.2.0 → 0.3.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.
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Async subprocess capture, duplicated from repo-tools/lib/subprocess.ts:
3
+ * rt-client has no dependency on rt's lib/, so this can't import runCapture
4
+ * from there. lib/subprocess.ts is the authority — change there first,
5
+ * mirror here.
6
+ *
7
+ * execSync blocks the event loop for the entire child lifetime; identity
8
+ * derivation must stay safe to call from daemon contexts, hence this instead.
9
+ */
10
+ export interface RunResult {
11
+ stdout: string;
12
+ stderr: string;
13
+ exitCode: number;
14
+ }
15
+ /**
16
+ * Run argv and capture stdout. Never throws: spawn failures and timeouts
17
+ * surface as a non-zero exitCode with whatever stdout was collected.
18
+ *
19
+ * Children inherit the caller's live `process.env` unless `opts.env` overrides it.
20
+ */
21
+ export declare function runCapture(argv: [string, ...string[]], opts?: {
22
+ cwd?: string;
23
+ timeoutMs?: number;
24
+ stderr?: "ignore" | "pipe";
25
+ env?: Record<string, string | undefined>;
26
+ }): Promise<RunResult>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Repo identity: the normalized-remote string that keys `repos.<identity>`
3
+ * sections in every settings store (RT-47 spec, "Repo identity").
4
+ *
5
+ * Identity is `host/path` (lowercase host, path case preserved), derived from
6
+ * `remote.origin.url` — never a filesystem path, so it is checkout-location
7
+ * independent: every worktree of a repo shares the same remote and therefore
8
+ * the same identity. A remote that doesn't match a recognized host form
9
+ * (bare local paths are the main case — repos.json has two) normalizes to
10
+ * null, meaning repo-scoped sections are unreachable for it and only global
11
+ * scopes apply. That's an honest degrade, not a crash.
12
+ *
13
+ * Three entry points:
14
+ * - `normalizeRemote` is the pure string transform, no I/O.
15
+ * - `identityFromRemote` layers the machine store's fork/multi-remote
16
+ * overrides (`rt.repoIdentityOverrides`, keyed by observed remote URL) on
17
+ * top of `normalizeRemote`. It's synchronous — the one helper every
18
+ * non-derivation site uses (run.ts, buildInterceptRules, tests) — so
19
+ * fork-pinning works everywhere identity is computed from a remote in
20
+ * hand, not just at derivation time.
21
+ * - `deriveRepoIdentity` is the async entry point for when only a repo path
22
+ * is in hand: it shells out to git for the remote (never a sync spawn —
23
+ * this must stay safe to call from daemon contexts) and then routes
24
+ * through `identityFromRemote`, memoized per path so repeated callers in
25
+ * one process don't re-spawn git.
26
+ */
27
+ /**
28
+ * Pure normalization: `remote` → `host/path` (lowercase host, `.git` and
29
+ * embedded credentials stripped) or null when the remote doesn't match a
30
+ * recognized host form (local paths, garbage input).
31
+ */
32
+ export declare function normalizeRemote(remote: string): string | null;
33
+ /**
34
+ * The sync helper every non-derivation call site uses: machine-store
35
+ * fork/multi-remote overrides (exact remote-URL match) then normalizeRemote.
36
+ * Reads the machine store fresh each call (files are small; store reads are
37
+ * not memoized anywhere in the resolver design).
38
+ */
39
+ export declare function identityFromRemote(remote: string): string | null;
40
+ /**
41
+ * Async derivation from a repo path: `git -C <repoPath> config --get
42
+ * remote.origin.url`, then identityFromRemote (so overrides apply to
43
+ * derivation too). Never a sync spawn — safe to call from daemon contexts.
44
+ *
45
+ * Only a SUCCESSFUL derivation (non-null identity) is memoized, for the life
46
+ * of the process; a remote change after that first success is NOT picked up
47
+ * until clearIdentityMemo() — documented behavior, not a bug (see spec:
48
+ * derivation is a one-time capture per process, not a live poll). A FAILED
49
+ * derivation (no remote yet, git not initialized yet, etc.) is never cached
50
+ * and is retried on every subsequent call — a caller racing repo
51
+ * provisioning (mid-clone, daemon-startup) must not permanently lose
52
+ * identity for a path just because it asked too early.
53
+ */
54
+ export declare function deriveRepoIdentity(repoPath: string): Promise<string | null>;
55
+ /** Test-only: clear the derivation memo so a test can force re-derivation. */
56
+ export declare function clearIdentityMemo(): void;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Settings-store path layout, duplicated from repo-tools/lib/rt-paths.ts:
3
+ * rt-client has no dependency on rt's lib/, so these literals cannot import
4
+ * rtDir()/userSettingsPath()/etc. lib/rt-paths.ts is the authority — change
5
+ * there first, mirror here (same convention as transport.ts's DEFAULT_SOCK
6
+ * and repos.ts's defaultReposJsonPath).
7
+ *
8
+ * HOME is resolved at CALL time via `process.env.HOME ?? homedir()`, matching
9
+ * the original, so tests can repoint the whole tree at a temp dir.
10
+ */
11
+ /** ~/.mattstack/user/settings.user.jsonc — the user store. */
12
+ export declare function userSettingsPath(): string;
13
+ /** ~/.mattstack/teams/<team>/mattstack/settings.team.jsonc — the team store. */
14
+ export declare function teamSettingsPath(team: string): string;
15
+ /**
16
+ * ~/.mattstack/user/local/<machineKey()>/settings.local.jsonc — the machine
17
+ * store, TRACKED and keyed per machine (path literals legal here only).
18
+ */
19
+ export declare function machineSettingsPath(): string;
20
+ /** ~/.mattstack/teams — the container every team's local clone lives under. */
21
+ export declare function teamsDir(): string;
22
+ /**
23
+ * The stable per-machine key that scopes the machine settings store — so
24
+ * `user/local/<key>/` never collides across machines sharing one synced
25
+ * `user/` tree.
26
+ *
27
+ * 1. `~/.mattstack/machine-key`, trimmed, if present, non-empty, and a SAFE
28
+ * PATH SEGMENT (no `/` or `\`, not `.` or `..`) — an explicit override
29
+ * for machines whose hostname isn't stable or unique (fresh installs,
30
+ * cloned VMs). The value becomes a directory name directly under
31
+ * `user/local/`, so anything else (a separator, or a segment that would
32
+ * walk up/stay put) is treated exactly as if the file were absent,
33
+ * rather than let the override escape that directory.
34
+ * 2. Otherwise the hostname, slugified: lowercased, a trailing `.local`
35
+ * dropped (mDNS suffix, not part of the identity), every run of
36
+ * characters outside `[a-z0-9-]` collapsed to one `-`, leading/trailing
37
+ * `-` trimmed. An all-illegal hostname slugs to `""`, which falls back
38
+ * to `"default"` rather than producing an empty path segment.
39
+ */
40
+ export declare function machineKey(): string;
41
+ /** Mirrored verbatim from lib/rt-paths.ts's isSafeMachineKeySegment — the two must agree or a machine-key value could pass one side's check and fail the other's. */
42
+ export declare function isSafeMachineKeySegment(v: string): boolean;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The settings key TABLE (RT-47/RT-50): rt's rows, plus the suite rows
3
+ * (deck/board/gitq/mattstack/claude) — the machinery in registry-machinery.ts
4
+ * that reads and validates against this table does not change when rows are
5
+ * added.
6
+ *
7
+ * Suite rows omit `migrated` (see registry-machinery.ts's docblock): they
8
+ * carry no rt-legacy file to migrate from, so `isMigrated()` treats the
9
+ * absent flag as resolver-backed from day one.
10
+ */
11
+ import type { SettingDef } from "./registry-machinery.ts";
12
+ export declare const REGISTRY: readonly SettingDef[];
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The settings schema registry machinery (RT-47/RT-50): lookup and
3
+ * validation over a static table describing every known settings key.
4
+ *
5
+ * This module is pure data plumbing — no file IO, no daemon dependency, safe
6
+ * to import anywhere (including the daemon thread). The def TABLE itself
7
+ * lives in registry-defs.ts (rt's rows today; a later task adds the rest of
8
+ * the suite); this file only knows how to look a def up and check a value
9
+ * against it.
10
+ *
11
+ * `migrated: true` means the reader for this key goes through the resolver.
12
+ * `migrated: false` keys still appear in `rt settings list` (so the full
13
+ * settings map is visible even before a key's reader has been ported), but
14
+ * `set` on them refuses — see the spec's "Schema registry" section for why
15
+ * writing a value nothing reads is the dishonesty class this design bans.
16
+ *
17
+ * `migrated` is omitted entirely (not `undefined` written out) for suite keys
18
+ * outside rt's wave-1 legacy-file migration — deck/board/gitq/mattstack/claude
19
+ * defs have no legacy file to migrate FROM, so the flag is meaningless for
20
+ * them. `isMigrated()` is the one place that turns absence into "yes,
21
+ * resolver-backed" — every other module must call it rather than testing
22
+ * `def.migrated` directly, or a suite key's absent flag reads as `false` and
23
+ * `set` refuses it.
24
+ */
25
+ export type SettingScope = "user" | "team" | "machine";
26
+ export interface SettingDef {
27
+ key: string;
28
+ type: "string" | "number" | "boolean" | "object" | "array";
29
+ scopes: SettingScope[];
30
+ default?: unknown;
31
+ merge: "replace" | "deep";
32
+ teamLocked?: boolean;
33
+ secret?: boolean;
34
+ repoScoped?: boolean;
35
+ migrated?: boolean;
36
+ legacyFile?: string;
37
+ pathGuardFields?: string[];
38
+ description: string;
39
+ }
40
+ /** Looks up a def by its flat namespaced key (e.g. "rt.roles"). */
41
+ export declare function getDef(key: string): SettingDef | undefined;
42
+ /** Every registered def, in registry declaration order. */
43
+ export declare function allDefs(): SettingDef[];
44
+ /** True unless `def.migrated` is explicitly `false` — see the module doc. */
45
+ export declare function isMigrated(def: SettingDef): boolean;
46
+ /**
47
+ * Checks whether `value` is a legal value for `def`: the JSON-ish type
48
+ * matches def.type, and (when def.pathGuardFields is set) no guarded field
49
+ * anywhere in the value looks like an absolute path or home-relative path
50
+ * literal — those are only legal in the machine store's own file contents,
51
+ * never as a shared-scope value (spec: "No path type exists ... enforced for
52
+ * wave-1 keys on the hook field specifically").
53
+ */
54
+ export declare function validateValue(def: SettingDef, value: unknown): {
55
+ ok: true;
56
+ } | {
57
+ ok: false;
58
+ reason: string;
59
+ };
@@ -0,0 +1,141 @@
1
+ /**
2
+ * The settings resolver (RT-47): one read path that layers the four stores
3
+ * and the registry default into a single answer plus the provenance that
4
+ * explains it.
5
+ *
6
+ * Scope ladder, weakest → strongest:
7
+ *
8
+ * default < team < user < team.repo < user.repo < machine < machine.repo
9
+ *
10
+ * Merge is per-key schema, never global (`SettingDef.merge`):
11
+ * - `replace` — the strongest valid scope wins atomically; provenance has
12
+ * exactly one entry.
13
+ * - `deep` — object values overlay field-by-field walking weakest → strongest;
14
+ * arrays and scalars inside a deep key still replace atomically. Provenance
15
+ * lists every scope that still owns at least one leaf of the resolved value,
16
+ * weakest-first — a scope whose every field was overridden is NOT listed
17
+ * (same honesty rule that makes `replace` provenance length 1).
18
+ *
19
+ * Degrade rules (teammates run version-skewed binaries; one unknown key in the
20
+ * team store must never brick resolution):
21
+ * - explicit `get`/`explain` of an unregistered key → throw.
22
+ * - unregistered keys FOUND in files → warn + skip, surfaced by `listSettings`
23
+ * with `unregistered: true`.
24
+ * - a registered key whose found value fails validation → warn + skip THAT
25
+ * scope only, labeled `invalid` in list/explain; weaker and stronger scopes
26
+ * still apply.
27
+ *
28
+ * Three deliberate decisions this file makes that the spec left to the
29
+ * implementation:
30
+ * 1. **The path-literal guard is scope-aware.** `validateValue`'s guarded
31
+ * fields (`rt.roles.hook`) are only illegal in SHARED scopes. The machine
32
+ * store is explicitly allowed path literals. So team/user rungs get the
33
+ * full check, the machine rung gets the type check alone.
34
+ * 2. **A value found in a store the def does not allow is skipped**, labeled
35
+ * like any other invalid value (`rt.repoIdentityOverrides` is machine-only;
36
+ * honouring a team-store copy of it would defeat the schema).
37
+ * 3. **`explain` shows values AS AUTHORED** (never expanded) because its job
38
+ * is to say what is in which file, and **`list` degrades** an unexpandable
39
+ * value to its raw form with an `expandError` label rather than throwing —
40
+ * one bad value must not brick a survey of every key. `get` is the loud
41
+ * one: an unsatisfiable closed-set variable throws.
42
+ *
43
+ * The resolver is daemon-FREE and sync: no spawns anywhere, repo identity is a
44
+ * pre-derived input (see identity.ts for the async derivation). Store files are
45
+ * parsed fresh per call — they are small, and memoization is a later
46
+ * optimization that would need invalidation this wave does not have.
47
+ *
48
+ * Writes (`setSetting`) land in a later task; this module is read-side only.
49
+ */
50
+ export type Scope = "machine.repo" | "machine" | "user.repo" | "team.repo" | "user" | "team" | "default";
51
+ /** The scope ladder, weakest first. Also the order every result is built in. */
52
+ export declare const SCOPE_ORDER: Scope[];
53
+ export interface Provenance {
54
+ scope: Scope;
55
+ /** The file the value came from; null for the registry default. */
56
+ file: string | null;
57
+ }
58
+ export interface ResolveOpts {
59
+ /** Normalized repo identity (identity.ts). Null/absent = repo rungs are unreachable. */
60
+ repoIdentity?: string | null;
61
+ /** Expand closed-set variables in the resolved value. Default true. */
62
+ expand?: boolean;
63
+ expandCtx?: {
64
+ repoRoot?: string;
65
+ worktree?: string;
66
+ };
67
+ }
68
+ export interface Resolved<T> {
69
+ value: T;
70
+ /** ALWAYS an array, weakest-first. Length 1 for replace keys. */
71
+ provenance: Provenance[];
72
+ }
73
+ /** A scope whose authored value was found but refused (type, path guard, or store). */
74
+ export interface InvalidScope {
75
+ scope: Scope;
76
+ file: string | null;
77
+ reason: string;
78
+ }
79
+ export interface ListedSetting {
80
+ key: string;
81
+ value: unknown;
82
+ provenance: Provenance[];
83
+ migrated: boolean;
84
+ /** Present only for keys found in files but absent from the registry. */
85
+ unregistered?: true;
86
+ /** Scopes skipped during resolution, with the reason each was refused. */
87
+ invalid?: InvalidScope[];
88
+ /** Set when the value could not be expanded here; `value` is then raw. */
89
+ expandError?: string;
90
+ }
91
+ export interface ExplainRow {
92
+ scope: Scope;
93
+ file: string | null;
94
+ present: boolean;
95
+ /** The value AS AUTHORED — never variable-expanded. */
96
+ value?: unknown;
97
+ /** Set when the value was ignored because the key is teamLocked. */
98
+ shadowed?: "teamLocked";
99
+ /** Set when the value was refused; the reason it was refused. */
100
+ invalid?: string;
101
+ }
102
+ export interface ExpandCtx {
103
+ repoRoot?: string;
104
+ worktree?: string;
105
+ home: string;
106
+ teamsDir: string;
107
+ }
108
+ /**
109
+ * Replaces ONLY `${repoRoot}`, `${worktree}`, `${home}` and `${team:<name>}`.
110
+ * Every other `${...}` passes through verbatim — domain templates like the
111
+ * interceptor's `${port}` are not ours to expand, and the same string may hold
112
+ * both kinds, so substitution is per-occurrence. `${team:<name>}` is lexical:
113
+ * `<teamsDir>/<name>` with no existence check (a missing team surfaces at use
114
+ * time through the consumer's own fail-open path), but the name must be a
115
+ * single directory segment — see `teamPath`. A closed-set variable with no
116
+ * context in `ctx` throws — silently emitting a half-expanded path is the
117
+ * dishonesty this design bans.
118
+ *
119
+ * Recurses through arrays and plain objects; non-strings pass through. Never
120
+ * mutates its input.
121
+ */
122
+ export declare function expandVariables(value: unknown, ctx: ExpandCtx): unknown;
123
+ /**
124
+ * Resolves one key across the whole ladder. Throws for an unregistered key —
125
+ * an explicit get of something rt has never heard of is a caller bug, not a
126
+ * degrade (contrast: unknown keys FOUND in files, which only warn).
127
+ */
128
+ export declare function getSetting<T>(key: string, opts?: ResolveOpts): Resolved<T>;
129
+ /**
130
+ * Every registered key resolved (registry order), then every unregistered key
131
+ * found in the stores (alphabetical). Nothing here throws: a survey of the
132
+ * whole settings map must survive one bad value, so an unexpandable value
133
+ * degrades to its raw form plus an `expandError` label.
134
+ */
135
+ export declare function listSettings(opts?: ResolveOpts): ListedSetting[];
136
+ /**
137
+ * One row per reachable rung, weakest-first, with values AS AUTHORED. Repo
138
+ * rungs are omitted entirely when the key is not repoScoped or no identity was
139
+ * supplied — showing rungs that could never apply would be noise, not honesty.
140
+ */
141
+ export declare function explainSetting(key: string, opts?: ResolveOpts): ExplainRow[];
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Reading the four settings store files (RT-47).
3
+ *
4
+ * `readStore` is the shared "raw JSONC → {global, repos}" step every store
5
+ * (user/team/machine) goes through before the resolver layers them by scope.
6
+ * It uses jsonc-parser (`parse`) rather than lib/jsonc.ts's stripJsonc: this
7
+ * is the one place in rt that also needs to WRITE these files back with
8
+ * comments/formatting intact (via jsonc-parser's `modify`/`applyEdits`, added
9
+ * alongside `setSetting` in resolve.ts), and both directions should go
10
+ * through the same library. stripJsonc keeps its existing callers.
11
+ *
12
+ * A store file is honest-degrade, not throw-on-read: absent, empty, or
13
+ * malformed all resolve to an empty store rather than crashing a caller that
14
+ * just wants "whatever settings exist" (teammates run version-skewed
15
+ * binaries; a store file with content this rt can't parse must not brick
16
+ * every settings read).
17
+ */
18
+ export interface StoreFile {
19
+ /** Top-level keys other than "repos" — the global scope for this store. */
20
+ global: Record<string, unknown>;
21
+ /** The "repos" object, keyed by repo identity. Empty if absent. */
22
+ repos: Record<string, Record<string, unknown>>;
23
+ /** The path this store was read from (echoed back for provenance). */
24
+ file: string;
25
+ /** False only when the file does not exist at all. */
26
+ exists: boolean;
27
+ }
28
+ /**
29
+ * Reads and parses one settings store file. Never throws:
30
+ * - missing file → `{ exists: false }`, empty maps.
31
+ * - present but malformed (parse errors, or a root that isn't a JSON
32
+ * object) → `{ exists: true }`, empty maps, one console.warn.
33
+ * - present and well-formed → `{ exists: true }`, split into
34
+ * `global`/`repos`.
35
+ */
36
+ export declare function readStore(file: string): StoreFile;
37
+ /**
38
+ * Names of every team that has a local settings store — i.e. subdirectories
39
+ * of teamsDir() that contain mattstack/settings.team.jsonc. A team dir without a
40
+ * settings file (a clone mid-setup, or an unrelated directory) is not yet a
41
+ * team as far as the resolver is concerned.
42
+ *
43
+ * Honest-degrade like readStore, and for a sharper reason: this scan is on the
44
+ * path of EVERY settings resolution, so one bad directory entry must never
45
+ * brick `rt settings` or any reader behind it. A team clone that was symlinked
46
+ * in and later moved leaves a dangling symlink here, and the follow-the-link
47
+ * stat that keeps symlinked clones working throws ENOENT on exactly that — so
48
+ * the scan is guarded twice: around the readdir (an unreadable teams dir means
49
+ * no teams), and around EACH entry (a dangling link, an EACCES, or a stat that
50
+ * loses a race with a concurrent move skips that entry and leaves the healthy
51
+ * teams intact).
52
+ */
53
+ export declare function listTeams(): string[];
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The settings write path (RT-47): `setSetting` — a single, comment-preserving
3
+ * write into one of the three AUTHORED stores (user/team/machine; `default`
4
+ * is a read-only rung and never appears here).
5
+ *
6
+ * Writes go through jsonc-parser's `modify`/`applyEdits` rather than
7
+ * parse-mutate-stringify, so existing comments and formatting in the store
8
+ * file survive a write to an unrelated key (verified: `modify` only rewrites
9
+ * the minimal edit range needed for the touched path; everything else in the
10
+ * document — including comments — is untouched text).
11
+ *
12
+ * JSONPath segments are literal object keys, not `.`-namespaced walks: a
13
+ * global write targets `[key]` (e.g. `["rt.hooks"]`) and a repoScoped write
14
+ * targets `["repos", identity, key]`. A dotted key like `"rt.roles"` is one
15
+ * path segment, not two — jsonc-parser never splits on `.` (verified with a
16
+ * throwaway script against the installed 3.3.1). Missing parents (`"repos"`,
17
+ * or the identity's section under it) are created by `modify` itself, and
18
+ * that creation is comment-safe — no special-casing needed here.
19
+ *
20
+ * Creating an absent store file (user/machine only — see "Team selection"
21
+ * below for why team stores are never auto-created): the file is seeded
22
+ * in-memory as `// header comment\n{}\n` BEFORE the first `modify` call.
23
+ * This is required, not cosmetic — a verified footgun: running `modify` on a
24
+ * comment-only document with NO braces at all (e.g. just `// header\n`)
25
+ * places the new object first and re-emits the header AFTER the closing
26
+ * brace, which is backwards. Seeding an empty object ahead of time gives
27
+ * `modify` real JSON structure to edit into, and the header comment stays
28
+ * exactly where it was written, above the object.
29
+ *
30
+ * ── Refusals ────────────────────────────────────────────────────────────
31
+ * In order: unregistered key, migrated:false (naming `def.legacyFile`), a
32
+ * scope the def does not list, a
33
+ * repoIdentity supplied for a key that is not `repoScoped`, a value that
34
+ * fails `registry.validateValue` (type check + the path-literal guard), and
35
+ * finally — only for `scope: "team"` — a team store that cannot be resolved
36
+ * (see below). Filesystem-touching checks (team resolution) run last, after
37
+ * every pure/in-memory refusal, so a bad call never creates or touches a
38
+ * file it was going to refuse anyway.
39
+ *
40
+ * ── The path-literal guard is scope-aware ──────────────────────────────
41
+ * Mirrors `resolve.ts`'s `validateForScope`: `def.pathGuardFields` (wave 1:
42
+ * `rt.roles.hook`) is enforced at `user` and `team` scope, where a path
43
+ * literal would silently stop applying the moment a teammate's checkout (or
44
+ * this developer's own machine) sits at a different path. `machine` scope is
45
+ * exempt — it is the one store where a path literal is the CORRECT way to
46
+ * express something local-only, so writes there skip the guard entirely
47
+ * (implemented by stripping `pathGuardFields` before calling
48
+ * `validateValue`, same trick `resolve.ts` uses on the read side).
49
+ *
50
+ * ── Team selection (a design decision this task made, per the brief) ──
51
+ * The base signature (`setSetting(key, value, scope, opts?)`) is extended
52
+ * here with `opts.team`, an explicit team NAME to target. Selection rule for
53
+ * `scope: "team"`:
54
+ * - `opts.team` given → that team's store; refuse if it has no local
55
+ * settings file (a team dir can exist mid-clone without one — see
56
+ * `stores.ts#listTeams`).
57
+ * - `opts.team` omitted, exactly one team has a local store → use it.
58
+ * - `opts.team` omitted, zero or multiple teams have a local store →
59
+ * refuse with a clear error (asking for `opts.team` in the multiple
60
+ * case). Wave 1 ships exactly one team, so this is the common path; the
61
+ * alternative of silently picking "the first team alphabetically" was
62
+ * considered and rejected — guessing which team's shared file to mutate
63
+ * is exactly the silent-oracle behavior this design bans elsewhere.
64
+ * A team store is NEVER auto-created by `setSetting` — team stores are
65
+ * seeded by the migration/orchestrator step and live in a repo that needs a
66
+ * commit+push to reach teammates; conjuring one here would produce an
67
+ * uncommitted, unshared file masquerading as team state.
68
+ *
69
+ * Every successful `scope: "team"` write prints one reminder line to
70
+ * stderr: the edit only exists in this local clone until it is committed
71
+ * and pushed. No such reminder for `user`/`machine` (nothing to push there
72
+ * in wave 1).
73
+ *
74
+ * ── Malformed stores refuse rather than edit around the damage ─────────
75
+ * An existing store's on-disk text is parsed and checked (`assertEditableJsonc`)
76
+ * before `modify` ever runs: real parse errors, a non-object root, or a
77
+ * duplicate key anywhere in the tree all refuse with one message naming the
78
+ * file. The duplicate-key case is the sharp one — it is not a parse error at
79
+ * all (JSON's grammar permits it), but `modify` edits the FIRST occurrence by
80
+ * offset while every reader takes the LAST, so a naive edit-in-place would
81
+ * report success while the effective value never changes, and the file would
82
+ * still degrade to empty on the next `readStore`. Refusing is the only
83
+ * option that doesn't either lie about success or write a still-broken file.
84
+ *
85
+ * ── Writes are write-temp-then-rename ───────────────────────────────────
86
+ * Mirrors `lib/json-store.ts`'s `writeJson`: the edited text is written to a
87
+ * `<path>.<pid>.<random>.tmp` file in the SAME directory, then renamed onto
88
+ * the real path — stores never tear, matching the rest of rt's persistence.
89
+ * All three stores are tracked repos now, but nothing auto-commits a write
90
+ * (H2, the snapshot daemon, is unbuilt) — a torn write would sit as a
91
+ * corrupt uncommitted file until a human noticed. The tmp file carries the
92
+ * edited TEXT exactly as `applyEdits` produced it, never round-tripped
93
+ * through `JSON.stringify` — that's what keeps comments alive.
94
+ */
95
+ import { type SettingScope } from "./registry-machinery.ts";
96
+ export interface SetSettingOpts {
97
+ /** Normalized repo identity — required to target a repoScoped key's `repos.<identity>` section. */
98
+ repoIdentity?: string;
99
+ /**
100
+ * Which team's local store to write into, for `scope: "team"`. See the
101
+ * module doc's "Team selection" section. Ignored for `user`/`machine`.
102
+ */
103
+ team?: string;
104
+ }
105
+ /**
106
+ * Writes `value` for `key` into the given scope's store, preserving every
107
+ * existing comment and creating the file/section it needs. See the module
108
+ * doc for the full refusal list and the team-selection rule.
109
+ */
110
+ export declare function setSetting(key: string, value: unknown, scope: SettingScope, opts?: SetSettingOpts): void;
@@ -7,6 +7,13 @@ export interface RtClientOptions {
7
7
  sockPath?: string;
8
8
  wsUrl?: string;
9
9
  }
10
+ /**
11
+ * Display-only: a module-load snapshot for callers that just want to show
12
+ * the default path (no consumer imports it today — checked). `rtCommand`
13
+ * itself never reads this constant; it calls `defaultSock()` fresh on every
14
+ * invocation so a test can repoint `process.env.HOME` at any time before
15
+ * calling, not only before this module first loads.
16
+ */
10
17
  export declare const DEFAULT_SOCK: string;
11
18
  export declare function rtCommand<T = unknown>(cmd: string, payload: Record<string, unknown>, opts?: {
12
19
  sockPath?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mattstack/rt-client",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -14,6 +14,9 @@
14
14
  "peerDependencies": {
15
15
  "@mattstack/glance": ">=0.13.0"
16
16
  },
17
+ "dependencies": {
18
+ "jsonc-parser": "^3.3.1"
19
+ },
17
20
  "description": "Typed client for the rt daemon: repos, worktrees, ports, tokens, and the event relay",
18
21
  "license": "MIT",
19
22
  "repository": {
@@ -28,12 +31,17 @@
28
31
  "files": [
29
32
  "dist",
30
33
  "src",
34
+ "!src/**/__tests__",
31
35
  "LICENSE",
32
36
  "README.md"
33
37
  ],
38
+ "engines": {
39
+ "bun": ">=1.0.0"
40
+ },
34
41
  "scripts": {
35
42
  "build": "bun build src/index.ts --outdir dist --target node --format esm --packages external && tsc -p tsconfig.json",
36
- "check-types": "tsc --noEmit -p tsconfig.json"
43
+ "check-types": "tsc --noEmit -p tsconfig.json",
44
+ "prepack": "bun run build"
37
45
  },
38
46
  "publishConfig": {
39
47
  "access": "public"
package/src/client.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { rtCommand } from "./transport.ts";
7
7
  import type { RtResponse, RtClientOptions } from "./transport.ts";
8
- import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData } from "./commands.ts";
8
+ import type { DemandDecl, ProjectMRsData, DiscussionsData, MrByBranchData, ForgeSlug, ForgeTokenData, RunSummary, RunDetail } from "./commands.ts";
9
9
 
10
10
  /**
11
11
  * One repo's project open-MR store. A cold repo forces a full paginated sync
@@ -58,7 +58,7 @@ export function readMrsByBranch(
58
58
  * side: an untracked repo comes back `ok: false` with the `rt daemon track`
59
59
  * command to run, which is the fail-closed shape callers should surface
60
60
  * verbatim. Callers keep env-var precedence on their own side; this is the
61
- * fallback that replaces reading ~/.rt/secrets.json directly.
61
+ * fallback that replaces reading ~/.mattstack/rt/secrets.json directly.
62
62
  */
63
63
  export function resolveForgeToken(
64
64
  repoName: string,
@@ -71,3 +71,22 @@ export function resolveForgeToken(
71
71
  { sockPath: opts.sockPath, timeoutMs: 10_000 },
72
72
  );
73
73
  }
74
+
75
+ export function listRuns(
76
+ repo?: string,
77
+ opts: RtClientOptions = {},
78
+ ): Promise<RtResponse<{ runs: RunSummary[] }>> {
79
+ const payload: Record<string, unknown> = {};
80
+ if (repo !== undefined) payload.repo = repo;
81
+ return rtCommand<{ runs: RunSummary[] }>("runs:list", payload, { sockPath: opts.sockPath, timeoutMs: 10_000 });
82
+ }
83
+
84
+ export function getRun(
85
+ runId: string,
86
+ repo?: string,
87
+ opts: RtClientOptions = {},
88
+ ): Promise<RtResponse<RunDetail>> {
89
+ const payload: Record<string, unknown> = { runId };
90
+ if (repo !== undefined) payload.repo = repo;
91
+ return rtCommand<RunDetail>("runs:get", payload, { sockPath: opts.sockPath, timeoutMs: 10_000 });
92
+ }