@indigoai-us/hq-cli 5.49.0 → 5.50.1
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/commands/mcp-registration.d.ts +905 -0
- package/dist/commands/mcp-registration.js +2001 -0
- package/dist/commands/mcp-status.d.ts +130 -0
- package/dist/commands/mcp-status.js +406 -0
- package/dist/commands/pack-install.d.ts +62 -0
- package/dist/commands/pack-install.js +422 -14
- package/dist/commands/packs.js +28 -4
- package/dist/commands/pkg-install.js +5 -2
- package/dist/index.js +20 -3
- package/dist/types.d.ts +8 -1
- package/dist/utils/contribution-table.d.ts +103 -0
- package/dist/utils/contribution-table.js +65 -0
- package/dist/utils/environmental-error.d.ts +10 -0
- package/dist/utils/environmental-error.js +40 -0
- package/dist/utils/pack-contributions.d.ts +86 -10
- package/dist/utils/pack-contributions.js +130 -48
- package/dist/utils/secrets-cache.d.ts +9 -0
- package/dist/utils/secrets-cache.js +24 -2
- package/package.json +3 -2
- package/scripts/generate-scan-packages-table.mjs +113 -0
- package/src/commands/mcp-registration.test.ts +2787 -0
- package/src/commands/mcp-registration.ts +2612 -0
- package/src/commands/mcp-status.test.ts +483 -0
- package/src/commands/mcp-status.ts +575 -0
- package/src/commands/mcp-status.us011.test.ts +243 -0
- package/src/commands/pack-install.test.ts +589 -0
- package/src/commands/pack-install.ts +497 -13
- package/src/commands/packs.ts +26 -1
- package/src/commands/pkg-install.ts +4 -1
- package/src/index.ts +18 -1
- package/src/types.ts +9 -8
- package/src/utils/contribution-table.ts +83 -0
- package/src/utils/environmental-error.test.ts +45 -0
- package/src/utils/environmental-error.ts +39 -0
- package/src/utils/pack-contributions.test.ts +257 -25
- package/src/utils/pack-contributions.ts +177 -47
- package/src/utils/secrets-cache.ts +22 -0
- package/test/e2e/smoke-install-mcp.sh +113 -0
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
|
@@ -0,0 +1,905 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP registration — the safe-write substrate for user-global config merges (US-006).
|
|
3
|
+
*
|
|
4
|
+
* Symlink contributions (workers, knowledge, skills, commands, hooks, policies,
|
|
5
|
+
* scripts) reach the host via a single `ln -s` into a well-known directory. The
|
|
6
|
+
* `mcp` key is DIFFERENT: it is `wire: 'merge'` in the single-source
|
|
7
|
+
* `CONTRIBUTION_TABLE`, meaning its per-server manifests are MERGED into the
|
|
8
|
+
* shared Claude (JSON) + Codex (TOML) agent configs rather than symlinked. A
|
|
9
|
+
* merge contribution must NEVER be symlinked — doing so would dump raw manifest
|
|
10
|
+
* JSON into a host directory instead of registering the server.
|
|
11
|
+
*
|
|
12
|
+
* This module ships the CRASH-SAFE, REVERSIBLE config-write CORE that every
|
|
13
|
+
* merge emitter (US-007 Claude/JSON, US-008 Codex/TOML, US-009 uninstall) builds
|
|
14
|
+
* on. Editing a user's `~/.claude.json` / `~/.mcp.json` / `~/.codex/config.toml`
|
|
15
|
+
* is the single highest-danger surface in the project: those files hold the
|
|
16
|
+
* user's OWN non-HQ servers (figma, paper, superhuman, vyg-internal). A botched
|
|
17
|
+
* write corrupts them and `hq rescue` cannot un-corrupt a user config (it rolls
|
|
18
|
+
* back the HQ tree, not `~/`). So the only remediation is the backup this module
|
|
19
|
+
* takes BEFORE the first byte is written.
|
|
20
|
+
*
|
|
21
|
+
* The substrate is format-agnostic: callers pass a `parse`/`serialize`/`assertEntry`
|
|
22
|
+
* triple ({@link ConfigFormat}) so the SAME read->lock->merge->write->verify
|
|
23
|
+
* machinery drives both the JSON (Claude) and TOML (Codex) emitters without
|
|
24
|
+
* duplicating the dangerous parts. {@link writeConfigAtomic} is the entry point.
|
|
25
|
+
*
|
|
26
|
+
* Safe-write algorithm (per the review-report §6.2 data-flow), run inside an
|
|
27
|
+
* advisory O_EXCL lock that is held across read->merge->write:
|
|
28
|
+
*
|
|
29
|
+
* [0] acquire O_EXCL lock (~/.<file>.hqlock, stale-timeout + PID)
|
|
30
|
+
* [1] backup target -> ~/.hq/backups/mcp/<iso>-<pack>/ (mode 0600), MANDATORY,
|
|
31
|
+
* before the first write byte (reuses the hq rescue backup pattern)
|
|
32
|
+
* [2] read(target): ENOENT -> empty doc; 0-byte/whitespace -> empty doc;
|
|
33
|
+
* non-empty parse error -> ConfigParseError ABORT (write nothing, NEVER
|
|
34
|
+
* regenerate-from-template); other read error -> ConfigPermissionError ABORT
|
|
35
|
+
* [3] re-read INSIDE the lock (never carry a pre-lock parse) — closes the
|
|
36
|
+
* concurrent-write race
|
|
37
|
+
* [4..6] caller's merge fn transforms the doc; serialize via the format
|
|
38
|
+
* [7] write temp file IN THE SAME DIR (mode 0600) -> fsync -> rename onto
|
|
39
|
+
* REALPATH(target). For the ~/.mcp.json symlink: realpath FIRST, rename
|
|
40
|
+
* onto the RESOLVED file, NEVER replace the link.
|
|
41
|
+
* [8] verify: re-read + re-parse + assert entry present; on failure restore
|
|
42
|
+
* from the [1] backup and raise PartialRegistrationError
|
|
43
|
+
* [9] release lock (finally)
|
|
44
|
+
*
|
|
45
|
+
* Desired-state-convergence semantics: re-running is safe from ANY state,
|
|
46
|
+
* including PARTIAL — the merge is idempotent per surface, the backup is
|
|
47
|
+
* timestamped (never overwritten), and a crash leaves the original intact.
|
|
48
|
+
*
|
|
49
|
+
* US-007 (Claude/JSON) and US-008 (Codex/TOML) fill in the per-runtime EMITTERS
|
|
50
|
+
* on top of this core; {@link registerServer} fans one per-server manifest out
|
|
51
|
+
* across BOTH surfaces (Claude always; Codex when `~/.codex` exists, else a
|
|
52
|
+
* first-class skip), and {@link registerMcpServers} is the pack-install routing
|
|
53
|
+
* seam over it.
|
|
54
|
+
*/
|
|
55
|
+
/** Base for every MCP-registration error; carries a stable machine-checkable `code`. */
|
|
56
|
+
export declare abstract class McpRegistrationError extends Error {
|
|
57
|
+
abstract readonly code: string;
|
|
58
|
+
constructor(message: string);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* A NON-EMPTY config file did not parse (hand-edited / corrupted). The safe-write
|
|
62
|
+
* core ABORTS on this — it writes NOTHING and NEVER regenerates from a template,
|
|
63
|
+
* so a user's figma/superhuman/paper entries can never be clobbered by a
|
|
64
|
+
* regenerate-on-parse-error path.
|
|
65
|
+
*/
|
|
66
|
+
export declare class ConfigParseError extends McpRegistrationError {
|
|
67
|
+
readonly code: "ConfigParseError";
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* A read error that is NOT ENOENT (e.g. EACCES, broken symlink, EISDIR). The
|
|
71
|
+
* safe-write core ABORTS — it does not guess, does not fabricate, does not
|
|
72
|
+
* overwrite.
|
|
73
|
+
*/
|
|
74
|
+
export declare class ConfigPermissionError extends McpRegistrationError {
|
|
75
|
+
readonly code: "ConfigPermissionError";
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* A server name is already present in the target config with a DIFFERENT
|
|
79
|
+
* definition than the one being merged (def-equal = idempotent no-op;
|
|
80
|
+
* def-differs = this error). Emitters (US-007/008) raise it; declared here so
|
|
81
|
+
* the whole feature shares one taxonomy.
|
|
82
|
+
*/
|
|
83
|
+
export declare class McpNameCollisionError extends McpRegistrationError {
|
|
84
|
+
readonly code: "McpNameCollisionError";
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The Codex runtime is absent (`~/.codex` missing). This is a FIRST-CLASS SKIP,
|
|
88
|
+
* not a crash and NEVER a mkdir-p-fabricate. Thrown only where a caller needs to
|
|
89
|
+
* distinguish "Codex not installed" from a real error; the substrate exposes
|
|
90
|
+
* {@link codexHome} + {@link codexConfigPath} so emitters can branch on absence
|
|
91
|
+
* without ever creating the directory.
|
|
92
|
+
*/
|
|
93
|
+
export declare class CodexNotInstalledError extends McpRegistrationError {
|
|
94
|
+
readonly code: "CodexNotInstalledError";
|
|
95
|
+
}
|
|
96
|
+
/** A per-server MCP manifest failed shape/transport validation. */
|
|
97
|
+
export declare class McpManifestError extends McpRegistrationError {
|
|
98
|
+
readonly code: "McpManifestError";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Verify-after-write found the merged entry MISSING or the file unreadable after
|
|
102
|
+
* the atomic rename — a partial/torn registration. The substrate restores the
|
|
103
|
+
* target from the pre-write backup before raising this, so the file is left in
|
|
104
|
+
* its original state, never a half-written one.
|
|
105
|
+
*/
|
|
106
|
+
export declare class PartialRegistrationError extends McpRegistrationError {
|
|
107
|
+
readonly code: "PartialRegistrationError";
|
|
108
|
+
/** The backup directory the target was (or could be) restored from. */
|
|
109
|
+
readonly backupDir: string | undefined;
|
|
110
|
+
constructor(message: string, backupDir?: string);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Thrown by not-yet-implemented seams (the US-007/US-008 Claude/Codex emitters).
|
|
114
|
+
* Carries a stable `code` so callers/tests can identify it without string-
|
|
115
|
+
* matching the message, and a `story` for at-a-glance attribution. KEPT from the
|
|
116
|
+
* US-005 seam: the merge EMITTERS are still pending after US-006 (which ships the
|
|
117
|
+
* safe-write substrate beneath them).
|
|
118
|
+
*/
|
|
119
|
+
export declare class NotImplementedError extends Error {
|
|
120
|
+
/** Stable, machine-checkable discriminator. */
|
|
121
|
+
readonly code: "NotImplemented";
|
|
122
|
+
/** The PRD story that lands the real implementation. */
|
|
123
|
+
readonly story: string;
|
|
124
|
+
constructor(message: string, story?: string);
|
|
125
|
+
}
|
|
126
|
+
/** Roots the substrate's view of the filesystem; everything is derived from `home`. */
|
|
127
|
+
export interface SafeWriteEnv {
|
|
128
|
+
/** The user home directory. Tests MUST pass a tmpdir here. */
|
|
129
|
+
home: string;
|
|
130
|
+
}
|
|
131
|
+
/** Resolve the env, defaulting `home` to the real `os.homedir()` for production callers. */
|
|
132
|
+
export declare function resolveEnv(env?: Partial<SafeWriteEnv>): SafeWriteEnv;
|
|
133
|
+
/** `~/.hq/backups/mcp` — the timestamped backup root (reuses the hq rescue location). */
|
|
134
|
+
export declare function backupRoot(env: SafeWriteEnv): string;
|
|
135
|
+
/** `~/.hq/logs/mcp-registry.log` — the append-only JSONL audit log (mirrors backupRoot style). */
|
|
136
|
+
export declare function mcpRegistryLogPath(env: SafeWriteEnv): string;
|
|
137
|
+
/** The action recorded on an audit line. */
|
|
138
|
+
export type AuditAction = 'register' | 'unregister';
|
|
139
|
+
/**
|
|
140
|
+
* The result recorded on an audit line:
|
|
141
|
+
* - `registered` — a register write that changed the file.
|
|
142
|
+
* - `unregistered` — an unregister write that removed an entry (file changed).
|
|
143
|
+
* - `noop` — an idempotent no-op (changed:false; prevHash === newHash).
|
|
144
|
+
* - `skipped` — a surface deliberately not acted on (e.g. Codex absent, or a
|
|
145
|
+
* foreign/unstamped entry left in place). PARTIAL is auditable.
|
|
146
|
+
* - `error` — the surface emit threw (the operation failed for this surface).
|
|
147
|
+
*/
|
|
148
|
+
export type AuditResult = 'registered' | 'unregistered' | 'noop' | 'skipped' | 'error';
|
|
149
|
+
/**
|
|
150
|
+
* One append-only audit-log entry. EXACT shape (field order is the JSON key order):
|
|
151
|
+
* `{ ts, action, pack, server, transport, target, file, prevHash, newHash, result }`.
|
|
152
|
+
* `target` is a url (http/sse) or command(+args) (stdio) — NEVER a header/secret.
|
|
153
|
+
* `prevHash`/`newHash` are sha256 (hex) of the config file bytes before/after the
|
|
154
|
+
* write ('' when the file is absent).
|
|
155
|
+
*/
|
|
156
|
+
export interface AuditLogEntry {
|
|
157
|
+
/** ISO8601 timestamp (`new Date().toISOString()`). */
|
|
158
|
+
ts: string;
|
|
159
|
+
/** `register` | `unregister`. */
|
|
160
|
+
action: AuditAction;
|
|
161
|
+
/** The pack name. */
|
|
162
|
+
pack: string;
|
|
163
|
+
/** The bare server name. */
|
|
164
|
+
server: string;
|
|
165
|
+
/** The transport (`http` | `stdio` | `sse` | `unknown`). */
|
|
166
|
+
transport: string;
|
|
167
|
+
/** url (http/sse) or command(+args joined) (stdio); '' when unknown. NEVER a secret. */
|
|
168
|
+
target: string;
|
|
169
|
+
/** The realpath the surface wrote to, or the config path for a skip; '' when none. */
|
|
170
|
+
file: string;
|
|
171
|
+
/** sha256 (hex) of the config file bytes BEFORE the write, or '' when absent. */
|
|
172
|
+
prevHash: string;
|
|
173
|
+
/** sha256 (hex) of the config file bytes AFTER the write, or '' when removed/absent. */
|
|
174
|
+
newHash: string;
|
|
175
|
+
/** registered | unregistered | noop | skipped | error. */
|
|
176
|
+
result: AuditResult;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* sha256 (hex) of a file's CONTENTS, or '' when the file is absent/unreadable.
|
|
180
|
+
* Hashing the WHOLE file is intentional — it proves a mutation happened without
|
|
181
|
+
* leaking any content into the audit log.
|
|
182
|
+
*/
|
|
183
|
+
export declare function hashFileContents(target: string): string;
|
|
184
|
+
/**
|
|
185
|
+
* Append EXACTLY ONE JSON line (`JSON.stringify(entry) + '\n'`) to
|
|
186
|
+
* `~/.hq/logs/mcp-registry.log`, creating `~/.hq/logs/` as needed. BEST-EFFORT /
|
|
187
|
+
* NEVER-THROWS: a logging failure must NEVER break (or corrupt) the real
|
|
188
|
+
* register/unregister operation, so every error is swallowed. The `target` field
|
|
189
|
+
* is defensively passed through {@link redactSecrets} against the supplied
|
|
190
|
+
* `secrets` set (normally empty — `target` is a url/command, never a header).
|
|
191
|
+
*
|
|
192
|
+
* @param env the resolved env (tests pass a tmpdir home).
|
|
193
|
+
* @param entry the {@link AuditLogEntry} to append.
|
|
194
|
+
* @param secrets resolved secret plaintexts to defensively redact from `target`.
|
|
195
|
+
*/
|
|
196
|
+
export declare function appendAuditLog(env: SafeWriteEnv, entry: AuditLogEntry, secrets?: Iterable<string>): void;
|
|
197
|
+
/** `~/.codex` — its ABSENCE is a first-class skip signal (never fabricated). */
|
|
198
|
+
export declare function codexHome(env: SafeWriteEnv): string;
|
|
199
|
+
/** `~/.codex/config.toml` — the GLOBAL Codex MCP config (not the project file). */
|
|
200
|
+
export declare function codexConfigPath(env: SafeWriteEnv): string;
|
|
201
|
+
/** True iff the Codex runtime is installed (`~/.codex` exists). Never creates it. */
|
|
202
|
+
export declare function isCodexInstalled(env: SafeWriteEnv): boolean;
|
|
203
|
+
/** `~/.claude.json` — the PINNED single Claude surface (top-level mcpServers). */
|
|
204
|
+
export declare function claudeConfigPath(env: SafeWriteEnv): string;
|
|
205
|
+
/** `~/.mcp.json` — the project-pinned Claude surface (a symlink on this host). */
|
|
206
|
+
export declare function mcpJsonPath(env: SafeWriteEnv): string;
|
|
207
|
+
/** Outcome of {@link readConfigDoc}: a parsed doc plus whether the file pre-existed. */
|
|
208
|
+
export interface ReadConfigResult<T> {
|
|
209
|
+
/** The parsed doc, or the `emptyDoc` when the file is absent/empty. */
|
|
210
|
+
doc: T;
|
|
211
|
+
/** False when the file did not exist (ENOENT) or was 0-byte/whitespace. */
|
|
212
|
+
existed: boolean;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A config format: how to parse a file's text into a doc, serialize a doc back
|
|
216
|
+
* to text, and assert a named entry is present after a write. Lets the SAME
|
|
217
|
+
* safe-write core drive JSON (Claude) and TOML (Codex) without duplicating the
|
|
218
|
+
* dangerous read/lock/backup/verify parts.
|
|
219
|
+
*
|
|
220
|
+
* `parse` MUST throw on malformed NON-EMPTY input (the core maps that throw to
|
|
221
|
+
* {@link ConfigParseError} and ABORTS). `emptyDoc` is the doc to use when the
|
|
222
|
+
* file is absent or blank.
|
|
223
|
+
*/
|
|
224
|
+
export interface ConfigFormat<T> {
|
|
225
|
+
/** Parse non-empty file text into a doc; throw on malformed input. */
|
|
226
|
+
parse: (text: string) => T;
|
|
227
|
+
/** Serialize a doc back to file text. */
|
|
228
|
+
serialize: (doc: T) => string;
|
|
229
|
+
/** The doc representing an empty/new container (ENOENT or blank file). */
|
|
230
|
+
emptyDoc: () => T;
|
|
231
|
+
/** Throw iff `name` is NOT present (and well-formed) in `doc` — drives verify-after-write. */
|
|
232
|
+
assertEntry: (doc: T, name: string) => void;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Built-in JSON {@link ConfigFormat} for the Claude surfaces. Pretty-prints with
|
|
236
|
+
* a trailing newline; an empty doc is `{}`. Parsing the empty string is the
|
|
237
|
+
* caller's responsibility (the core normalizes blank files to `emptyDoc` BEFORE
|
|
238
|
+
* calling `parse`), so `parse` here always sees non-empty text.
|
|
239
|
+
*/
|
|
240
|
+
export declare const jsonFormat: ConfigFormat<Record<string, unknown>>;
|
|
241
|
+
/**
|
|
242
|
+
* Read + parse a config file, normalizing the SHADOW cases (review-report §6.2):
|
|
243
|
+
*
|
|
244
|
+
* - SHADOW A: ENOENT -> { doc: emptyDoc, existed: false } (fresh host / Codex absent)
|
|
245
|
+
* - SHADOW B: 0-byte / whitespace-only -> { doc: emptyDoc, existed: false }
|
|
246
|
+
* - SHADOW C: NON-EMPTY + parse error -> throw {@link ConfigParseError} (ABORT,
|
|
247
|
+
* write nothing, never regenerate-from-template)
|
|
248
|
+
* - SHADOW D: any OTHER read error (EACCES, EISDIR, broken symlink) ->
|
|
249
|
+
* throw {@link ConfigPermissionError} (ABORT)
|
|
250
|
+
*
|
|
251
|
+
* @param target the config file path (a symlink is read THROUGH transparently)
|
|
252
|
+
* @param format the {@link ConfigFormat} (parser + empty-doc factory)
|
|
253
|
+
*/
|
|
254
|
+
export declare function readConfigDoc<T>(target: string, format: ConfigFormat<T>): ReadConfigResult<T>;
|
|
255
|
+
/**
|
|
256
|
+
* Strip a parser error message down to its STRUCTURAL reason, dropping any embedded
|
|
257
|
+
* SOURCE-CONTEXT lines so on-disk file bytes (which may include an unenumerable
|
|
258
|
+
* pre-existing secret) can never leak into a {@link ConfigParseError} message.
|
|
259
|
+
*
|
|
260
|
+
* `smol-toml` formats its error as:
|
|
261
|
+
*
|
|
262
|
+
* Invalid TOML document: <structural reason>
|
|
263
|
+
* <blank line>
|
|
264
|
+
* <N>: <verbatim source line> ← can contain a secret
|
|
265
|
+
* <M>: <verbatim source line> ← can contain a secret
|
|
266
|
+
* ^ ← caret pointer
|
|
267
|
+
*
|
|
268
|
+
* We keep ONLY the lines up to the first blank line (the structural reason, e.g.
|
|
269
|
+
* `Invalid TOML document: only letter, numbers, dashes and underscores are allowed
|
|
270
|
+
* in keys`) and discard the source snippet + caret. As belt-and-suspenders, any
|
|
271
|
+
* surviving line that still looks like quoted content or a `Bearer <token>` value
|
|
272
|
+
* is redacted, so even a parser whose reason inlines a value (rather than a
|
|
273
|
+
* separate snippet block) cannot leak. The result never contains raw file content.
|
|
274
|
+
*/
|
|
275
|
+
export declare function sanitizeParserMessage(message: string): string;
|
|
276
|
+
/** Default stale-lock timeout — a lock older than this whose PID is dead is broken. */
|
|
277
|
+
export declare const DEFAULT_LOCK_STALE_MS = 30000;
|
|
278
|
+
/** Default total time to wait for a held lock before giving up. */
|
|
279
|
+
export declare const DEFAULT_LOCK_WAIT_MS = 10000;
|
|
280
|
+
/** A held advisory lock; release with {@link releaseLock} (idempotent). */
|
|
281
|
+
export interface ConfigLock {
|
|
282
|
+
/** The lockfile path (`~/.<basename>.hqlock`). */
|
|
283
|
+
lockPath: string;
|
|
284
|
+
}
|
|
285
|
+
/** Options for {@link acquireLock} — overridable so tests can use tiny timeouts. */
|
|
286
|
+
export interface AcquireLockOptions {
|
|
287
|
+
/** Age beyond which a dead-PID lock is considered stale + breakable. */
|
|
288
|
+
staleMs?: number;
|
|
289
|
+
/** Total time to spin waiting for a live lock before throwing. */
|
|
290
|
+
waitMs?: number;
|
|
291
|
+
/** Poll interval while waiting. */
|
|
292
|
+
pollMs?: number;
|
|
293
|
+
/** Injectable "is this PID alive?" check (tests stub it; prod uses `kill -0`). */
|
|
294
|
+
isPidAlive?: (pid: number) => boolean;
|
|
295
|
+
/** Injectable clock (tests). */
|
|
296
|
+
now?: () => number;
|
|
297
|
+
}
|
|
298
|
+
/** The lockfile path for a target: `~/.<basename>.hqlock` (a sibling, hidden). */
|
|
299
|
+
export declare function lockPathFor(target: string): string;
|
|
300
|
+
/**
|
|
301
|
+
* Acquire an advisory O_EXCL lock for `target`, held across the whole
|
|
302
|
+
* read->merge->write cycle so a concurrent register cannot interleave and lose a
|
|
303
|
+
* write. Behavior:
|
|
304
|
+
*
|
|
305
|
+
* - `O_EXCL | O_CREAT` create-or-fail; on EEXIST another holder exists.
|
|
306
|
+
* - A held lock whose PID is DEAD and whose mtime is older than `staleMs` is
|
|
307
|
+
* broken (unlinked) and re-acquired.
|
|
308
|
+
* - A held lock whose PID is ALIVE (or fresh) is waited on up to `waitMs`; if
|
|
309
|
+
* it never frees, this throws (the concurrent register ABORTS rather than
|
|
310
|
+
* racing — the first writer's work is preserved).
|
|
311
|
+
*
|
|
312
|
+
* The lockfile body is the holder's PID + an ISO timestamp (diagnostic).
|
|
313
|
+
*/
|
|
314
|
+
export declare function acquireLock(target: string, opts?: AcquireLockOptions): ConfigLock;
|
|
315
|
+
/** Release a held lock. Idempotent — a missing lockfile is not an error. */
|
|
316
|
+
export declare function releaseLock(lock: ConfigLock): void;
|
|
317
|
+
/** UTC `YYYY-MM-DDTHH-MM-SSZ` (dash-separated) — the hq rescue timestamp format. */
|
|
318
|
+
export declare function rescueStamp(d?: Date): string;
|
|
319
|
+
/**
|
|
320
|
+
* Back up `target` into `~/.hq/backups/mcp/<iso>-<pack>/` BEFORE the first write
|
|
321
|
+
* byte, mirroring the hq rescue snapshot. The backup copy is mode 0600. Returns
|
|
322
|
+
* the backup DIRECTORY (the restore target for verify-after-write).
|
|
323
|
+
*
|
|
324
|
+
* If `target` does not exist (fresh host), the backup dir is still created (so
|
|
325
|
+
* the restore path is always valid) but holds a `.absent` marker instead of a
|
|
326
|
+
* copy — restoring "absent" means unlinking the target.
|
|
327
|
+
*
|
|
328
|
+
* The backup is taken on the REALPATH of the target so a symlink (`~/.mcp.json`)
|
|
329
|
+
* is backed up by its resolved contents, never the link.
|
|
330
|
+
*/
|
|
331
|
+
export declare function backupConfig(env: SafeWriteEnv, target: string, pack: string, stamp?: string): string;
|
|
332
|
+
/**
|
|
333
|
+
* Restore `target` from a {@link backupConfig} directory. If the backup recorded
|
|
334
|
+
* absence (the `.absent` marker), the target is unlinked (returning it to "did
|
|
335
|
+
* not exist"); otherwise the backed-up bytes are written back atomically onto
|
|
336
|
+
* the realpath. Used by verify-after-write on failure.
|
|
337
|
+
*/
|
|
338
|
+
export declare function restoreFromBackup(target: string, backupDir: string): void;
|
|
339
|
+
/**
|
|
340
|
+
* Resolve `p` to its canonical real path. If `p` (or a path component) does not
|
|
341
|
+
* exist, fall back to `p` unchanged (a brand-new file has no realpath yet but we
|
|
342
|
+
* still write at its intended location). A symlink whose TARGET is missing
|
|
343
|
+
* (broken link) resolves the link itself to its dangling target so we never
|
|
344
|
+
* replace the link.
|
|
345
|
+
*/
|
|
346
|
+
export declare function realpathOrSelf(p: string, _depth?: number): string;
|
|
347
|
+
/**
|
|
348
|
+
* Atomically replace `realTarget` with `data`: write a temp file IN THE SAME DIR
|
|
349
|
+
* (mode 0600), fsync the FILE, rename onto `realTarget`, then fsync the
|
|
350
|
+
* DIRECTORY. The temp lives in the same directory so the rename is a same-
|
|
351
|
+
* filesystem atomic operation — a crash either leaves the original wholly intact
|
|
352
|
+
* or the new file wholly in place, never a torn write. The trailing directory
|
|
353
|
+
* fsync persists the rename itself: without it, a power-loss can lose the rename
|
|
354
|
+
* metadata even though the file bytes were flushed, silently reverting the write.
|
|
355
|
+
*
|
|
356
|
+
* `realTarget` MUST already be a realpath (caller resolves symlinks first) so we
|
|
357
|
+
* rename onto the RESOLVED file and NEVER replace a symlink with a regular file.
|
|
358
|
+
*/
|
|
359
|
+
export declare function atomicReplace(realTarget: string, data: string | Buffer): void;
|
|
360
|
+
/** Options for {@link writeConfigAtomic}. */
|
|
361
|
+
export interface WriteConfigOptions<T> {
|
|
362
|
+
/** The config file to merge into (a symlink is resolved before the rename). */
|
|
363
|
+
target: string;
|
|
364
|
+
/** The pack name — labels the backup directory. */
|
|
365
|
+
pack: string;
|
|
366
|
+
/** The format (parser/serializer/assertEntry) for `target`. */
|
|
367
|
+
format: ConfigFormat<T>;
|
|
368
|
+
/**
|
|
369
|
+
* Pure merge: receive the current doc, return the NEXT doc. MUST NOT mutate the
|
|
370
|
+
* input in a way that survives a throw — the substrate re-reads inside the lock
|
|
371
|
+
* and calls this on the locked doc, so it should be deterministic. Throwing
|
|
372
|
+
* (e.g. {@link McpNameCollisionError}) aborts the write cleanly.
|
|
373
|
+
*/
|
|
374
|
+
merge: (doc: T) => T;
|
|
375
|
+
/** The server name to assert present after the write (verify-after-write). */
|
|
376
|
+
assertName: string;
|
|
377
|
+
/** Injectable env (tests pass a tmpdir home). */
|
|
378
|
+
env?: Partial<SafeWriteEnv>;
|
|
379
|
+
/** Lock tuning (tests use tiny timeouts). */
|
|
380
|
+
lock?: AcquireLockOptions;
|
|
381
|
+
/** Backup timestamp override (tests / deterministic naming). */
|
|
382
|
+
stamp?: string;
|
|
383
|
+
}
|
|
384
|
+
/** Result of a successful {@link writeConfigAtomic}. */
|
|
385
|
+
export interface WriteConfigResult {
|
|
386
|
+
/** Whether the file changed (false = idempotent no-op; the merge returned an equal doc). */
|
|
387
|
+
changed: boolean;
|
|
388
|
+
/** The backup directory taken before the write (restore target). */
|
|
389
|
+
backupDir: string;
|
|
390
|
+
/** The realpath the bytes were written to (resolved symlink). */
|
|
391
|
+
realTarget: string;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Crash-safe, reversible, lock-guarded merge-write of one entry into a
|
|
395
|
+
* user-global agent config. Implements the full review-report §6.2 algorithm:
|
|
396
|
+
* lock -> backup -> read (shadow-normalized) -> RE-READ in lock -> merge ->
|
|
397
|
+
* serialize -> atomic temp+fsync+rename onto realpath -> verify -> restore on
|
|
398
|
+
* failure. Releases the lock in `finally`.
|
|
399
|
+
*
|
|
400
|
+
* Idempotent: if `merge` returns a doc byte-identical (post-serialize) to the
|
|
401
|
+
* current file, no write happens (`changed: false`) — re-runs from ANY state,
|
|
402
|
+
* including PARTIAL, are safe.
|
|
403
|
+
*
|
|
404
|
+
* @throws ConfigParseError existing non-empty file is unparseable (ABORT, nothing written)
|
|
405
|
+
* @throws ConfigPermissionError read/lock failure other than ENOENT (ABORT)
|
|
406
|
+
* @throws PartialRegistrationError verify-after-write failed; target restored from backup
|
|
407
|
+
* @throws (whatever `merge` throws, e.g. McpNameCollisionError) — aborts before any write
|
|
408
|
+
*/
|
|
409
|
+
export declare function writeConfigAtomic<T>(opts: WriteConfigOptions<T>): WriteConfigResult;
|
|
410
|
+
/** Server-name charset gate: load-bearing as the `mcp__<name>__*` tool namespace. */
|
|
411
|
+
export declare const MCP_SERVER_NAME_RE: RegExp;
|
|
412
|
+
/** The provenance key stamped onto every HQ-registered server (US-009 uninstall scope). */
|
|
413
|
+
export declare const HQ_PACK_PROVENANCE_KEY = "_hqPack";
|
|
414
|
+
/**
|
|
415
|
+
* A per-server MCP manifest as shipped in a pack (`mcp/<name>.json`). The shape is
|
|
416
|
+
* validated upstream by US-005's `validateMcpManifest`; here we treat it as a
|
|
417
|
+
* read-only record and only touch `headers`/`env` for secret resolution. `type`
|
|
418
|
+
* and the transport fields pass through to the emitted Claude server def verbatim.
|
|
419
|
+
*/
|
|
420
|
+
export interface McpManifest {
|
|
421
|
+
type: 'http' | 'stdio' | 'sse';
|
|
422
|
+
url?: string;
|
|
423
|
+
headers?: Record<string, string>;
|
|
424
|
+
command?: string;
|
|
425
|
+
args?: string[];
|
|
426
|
+
env?: Record<string, string>;
|
|
427
|
+
tools?: Record<string, unknown>;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Resolve a `${secret:NAME}` reference to its plaintext value, or return `null`
|
|
431
|
+
* when the secret is unavailable (un-minted / TTL-expired / no company context).
|
|
432
|
+
* Production binds this to `secrets-cache.ts` (`readCache(companyUid, NAME)`,
|
|
433
|
+
* AES-256-GCM, 0600, 5-min TTL); TESTS inject a pure map so they NEVER read the
|
|
434
|
+
* real encrypted cache. Returning `null` for a referenced secret is a hard error
|
|
435
|
+
* at emit (we refuse to write a broken header), distinct from a name with no
|
|
436
|
+
* reference at all.
|
|
437
|
+
*/
|
|
438
|
+
export type SecretResolver = (name: string) => string | null;
|
|
439
|
+
/** True iff `value` contains at least one `${secret:NAME}` reference. */
|
|
440
|
+
export declare function hasSecretRef(value: string): boolean;
|
|
441
|
+
/**
|
|
442
|
+
* Replace every `${secret:NAME}` in `value` with its resolved plaintext, tracking
|
|
443
|
+
* each resolved literal so the caller can REDACT it from any output. Throws
|
|
444
|
+
* {@link McpManifestError} if a referenced secret cannot be resolved (we refuse to
|
|
445
|
+
* emit a half-resolved header — better a clear failure than a silently-broken
|
|
446
|
+
* server). A string with no reference passes through untouched.
|
|
447
|
+
*
|
|
448
|
+
* @param value the header/env value (e.g. `"Bearer ${secret:VYG_API_KEY}"`)
|
|
449
|
+
* @param resolve the {@link SecretResolver}
|
|
450
|
+
* @param secretSink accumulates every resolved plaintext (for redaction); may be
|
|
451
|
+
* the SAME set across many values so one `redactSecrets` call
|
|
452
|
+
* scrubs them all.
|
|
453
|
+
*/
|
|
454
|
+
export declare function resolveSecretRefs(value: string, resolve: SecretResolver, secretSink: Set<string>): string;
|
|
455
|
+
/** The redaction marker substituted for any resolved secret value in output. */
|
|
456
|
+
export declare const SECRET_REDACTION = "\u00ABredacted\u00BB";
|
|
457
|
+
/**
|
|
458
|
+
* Scrub every resolved secret plaintext out of `text`, replacing it with
|
|
459
|
+
* {@link SECRET_REDACTION}. Use on ANYTHING that might be logged/echoed/returned
|
|
460
|
+
* after an emit — the no-echo rule is absolute. Longest-first replacement avoids a
|
|
461
|
+
* shorter secret unmasking a longer one that contains it.
|
|
462
|
+
*/
|
|
463
|
+
export declare function redactSecrets(text: string, secrets: Iterable<string>): string;
|
|
464
|
+
/**
|
|
465
|
+
* The Claude `ConfigFormat` for the top-level `~/.claude.json` surface: the doc is
|
|
466
|
+
* the whole `~/.claude.json` object, the entry we assert is `mcpServers.<name>`.
|
|
467
|
+
* Reuses {@link jsonFormat}'s parse/serialize (object-rooted JSON, pretty-printed
|
|
468
|
+
* with a trailing newline) so existing servers — and every UNRELATED top-level key
|
|
469
|
+
* in `~/.claude.json` (projects, userID, tipsHistory, …) — round-trip byte-for-byte.
|
|
470
|
+
*/
|
|
471
|
+
export declare const claudeConfigFormat: ConfigFormat<Record<string, unknown>>;
|
|
472
|
+
/**
|
|
473
|
+
* Build the Claude server definition emitted into `mcpServers.<name>` from a
|
|
474
|
+
* manifest: pass the transport fields through, resolve `${secret:}` in every
|
|
475
|
+
* header/env value (recording the plaintexts in `secretSink` for redaction), and
|
|
476
|
+
* stamp `_hqPack` provenance. The output object's key order is deterministic so a
|
|
477
|
+
* re-run produces a byte-identical def (idempotency depends on stable serialize).
|
|
478
|
+
*/
|
|
479
|
+
export declare function buildClaudeServerDef(manifest: McpManifest, pack: string, resolve: SecretResolver, secretSink: Set<string>): Record<string, unknown>;
|
|
480
|
+
/**
|
|
481
|
+
* Compare two server defs for COLLISION purposes, ignoring the `_hqPack`
|
|
482
|
+
* provenance stamp (a user-created server has no stamp; an HQ re-install carries
|
|
483
|
+
* one — neither difference is a real definitional conflict). Returns true iff the
|
|
484
|
+
* transport-relevant content is identical. Used to distinguish def-equal (no-op)
|
|
485
|
+
* from def-differs (abort) when a name is already present.
|
|
486
|
+
*/
|
|
487
|
+
export declare function serverDefsEqual(a: unknown, b: unknown): boolean;
|
|
488
|
+
/**
|
|
489
|
+
* The merge fn for the Claude surface: insert/converge `mcpServers.<name> = def`.
|
|
490
|
+
*
|
|
491
|
+
* - name ABSENT -> insert (creating `mcpServers` if needed).
|
|
492
|
+
* - name PRESENT, def-equal (provenance-ignored) -> NO-OP (idempotent re-install
|
|
493
|
+
* / PARTIAL self-heal). The provenance stamp is (re)applied so an entry the
|
|
494
|
+
* user happened to define identically becomes HQ-owned only when we truly own
|
|
495
|
+
* it — but byte-equality of the rest means {@link writeConfigAtomic} writes
|
|
496
|
+
* nothing unless the stamp itself is the only delta, which we treat as a no-op
|
|
497
|
+
* by returning the original doc.
|
|
498
|
+
* - name PRESENT, def-DIFFERS -> {@link McpNameCollisionError} ABORT.
|
|
499
|
+
*
|
|
500
|
+
* Never touches any OTHER server entry or any other top-level key.
|
|
501
|
+
*/
|
|
502
|
+
export declare function mergeClaudeServer(name: string, def: Record<string, unknown>): (doc: Record<string, unknown>) => Record<string, unknown>;
|
|
503
|
+
/** Options for {@link registerServer} / {@link registerClaudeServer}. */
|
|
504
|
+
export interface RegisterServerOptions {
|
|
505
|
+
/** The bare server name (the `mcpServers` key + `mcp__<name>__*` namespace). */
|
|
506
|
+
name: string;
|
|
507
|
+
/** The shape-validated per-server manifest (`mcp/<name>.json`). */
|
|
508
|
+
manifest: McpManifest;
|
|
509
|
+
/** The pack name — stamped as `_hqPack` provenance + labels the backup dir. */
|
|
510
|
+
pack: string;
|
|
511
|
+
/** Resolves `${secret:NAME}` at emit. Defaults to a no-secret resolver (errors on any ref). */
|
|
512
|
+
resolveSecret?: SecretResolver;
|
|
513
|
+
/** Injectable env (tests pass a tmpdir home so the real ~/.claude.json is untouched). */
|
|
514
|
+
env?: Partial<SafeWriteEnv>;
|
|
515
|
+
/** Lock tuning (tests use tiny timeouts). */
|
|
516
|
+
lock?: AcquireLockOptions;
|
|
517
|
+
/** Backup timestamp override (deterministic tests). */
|
|
518
|
+
stamp?: string;
|
|
519
|
+
/**
|
|
520
|
+
* Append an audit-log line per surface (default true). The AC requires every
|
|
521
|
+
* register to log; this exists ONLY to suppress logging in narrow unit tests
|
|
522
|
+
* (e.g. {@link registerClaudeServer}/{@link registerCodexServer} are called
|
|
523
|
+
* directly there and do their own thing). The audit append goes through the
|
|
524
|
+
* injected `env.home`, so in tests it lands in the tmpdir — never the real ~/.
|
|
525
|
+
*/
|
|
526
|
+
audit?: boolean;
|
|
527
|
+
}
|
|
528
|
+
/** Outcome of registering one server into one surface. */
|
|
529
|
+
export interface RegisterSurfaceResult {
|
|
530
|
+
/** Whether the surface's file actually changed (false = idempotent no-op). */
|
|
531
|
+
changed: boolean;
|
|
532
|
+
/** The realpath the bytes were written to (resolved symlink). */
|
|
533
|
+
realTarget: string;
|
|
534
|
+
}
|
|
535
|
+
/** A surface that was deliberately not acted on (e.g. Codex-absent host). */
|
|
536
|
+
export interface RegisterSurfaceSkip {
|
|
537
|
+
/** Discriminant: this surface was skipped, not written. */
|
|
538
|
+
skipped: true;
|
|
539
|
+
/** Human-readable reason (e.g. "Codex runtime absent"). */
|
|
540
|
+
reason: string;
|
|
541
|
+
}
|
|
542
|
+
/** Outcome of {@link registerServer} — one entry per runtime surface acted on. */
|
|
543
|
+
export interface RegisterServerResult {
|
|
544
|
+
/** The Claude/JSON surface result (always present — US-007). */
|
|
545
|
+
claude: RegisterSurfaceResult;
|
|
546
|
+
/**
|
|
547
|
+
* The Codex/TOML surface result (US-008), or a first-class SKIP when the Codex
|
|
548
|
+
* runtime is absent (`~/.codex` missing — never fabricated).
|
|
549
|
+
*/
|
|
550
|
+
codex: RegisterSurfaceResult | RegisterSurfaceSkip;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Validate a server name against {@link MCP_SERVER_NAME_RE} BEFORE it is ever
|
|
554
|
+
* interpolated into a JSON key (or, in US-008, a TOML header). A bad name is an
|
|
555
|
+
* injection vector (the name is load-bearing as the tool namespace), so this is a
|
|
556
|
+
* hard pre-flight gate. Throws {@link McpManifestError}.
|
|
557
|
+
*/
|
|
558
|
+
export declare function assertValidServerName(name: string): void;
|
|
559
|
+
/**
|
|
560
|
+
* Emit ONE server into the PINNED single Claude surface (top-level
|
|
561
|
+
* `~/.claude.json` `mcpServers`) via {@link writeConfigAtomic}. Resolves
|
|
562
|
+
* `${secret:}` headers at emit, stamps provenance, and is idempotent per surface
|
|
563
|
+
* (re-install no-ops; a PARTIAL state self-heals). Existing servers and every
|
|
564
|
+
* unrelated `~/.claude.json` key are preserved byte-for-byte by the merge.
|
|
565
|
+
*
|
|
566
|
+
* @returns the surface result (changed?, realTarget). Any error message the caller
|
|
567
|
+
* surfaces MUST be passed through {@link redactSecrets} — the resolved
|
|
568
|
+
* secret plaintexts are collected internally and never returned.
|
|
569
|
+
* @throws McpManifestError invalid server name or unresolvable `${secret:}`
|
|
570
|
+
* @throws McpNameCollisionError the name exists with a different definition
|
|
571
|
+
* @throws ConfigParseError / ConfigPermissionError / PartialRegistrationError (from the core)
|
|
572
|
+
*/
|
|
573
|
+
export declare function registerClaudeServer(opts: RegisterServerOptions): RegisterSurfaceResult;
|
|
574
|
+
/**
|
|
575
|
+
* Fan ONE server out across the per-runtime surfaces. US-007 wires the Claude/JSON
|
|
576
|
+
* arm ({@link registerClaudeServer}); US-008 wires the Codex/TOML arm
|
|
577
|
+
* ({@link registerCodexServer}). Each arm registers ONE server into its surface via
|
|
578
|
+
* {@link writeConfigAtomic} and is idempotent per surface (re-install no-ops; a
|
|
579
|
+
* PARTIAL state — one runtime wired, the other not — self-heals on re-run).
|
|
580
|
+
*
|
|
581
|
+
* The Codex arm is a FIRST-CLASS SKIP when the Codex runtime is absent (`~/.codex`
|
|
582
|
+
* missing): no crash, no fabricated directory. So a Claude-only host still registers
|
|
583
|
+
* Claude cleanly, and a re-run after a Codex install fills the second surface.
|
|
584
|
+
*
|
|
585
|
+
* ORDERING: Claude is emitted FIRST. If the Codex emit then throws, Claude is
|
|
586
|
+
* already durably registered and a re-run self-heals only the missing Codex surface
|
|
587
|
+
* (desired-state convergence) — the user is never left with NOTHING wired.
|
|
588
|
+
*
|
|
589
|
+
* Pre-flight collision spanning BOTH targets is enforced PER SURFACE by each
|
|
590
|
+
* emitter's merge (def-equal = no-op, def-differs = {@link McpNameCollisionError}).
|
|
591
|
+
*/
|
|
592
|
+
export declare function registerServer(opts: RegisterServerOptions): RegisterServerResult;
|
|
593
|
+
/**
|
|
594
|
+
* Derive the audit-log `target` field from a manifest: the url for http/sse, or
|
|
595
|
+
* `command` (plus space-joined args) for stdio. NEVER a header/secret. Returns ''
|
|
596
|
+
* when neither a url nor a command is present.
|
|
597
|
+
*/
|
|
598
|
+
export declare function manifestTarget(manifest: McpManifest): string;
|
|
599
|
+
/**
|
|
600
|
+
* Register one pack's MCP servers into the shared agent configs (the public seam
|
|
601
|
+
* `pack-install` routes `wire:'merge'` keys to). For each declared server name it
|
|
602
|
+
* loads + shape-checks the manifest, then fans out via {@link registerServer}.
|
|
603
|
+
*
|
|
604
|
+
* US-007 wires the Claude/JSON surface; the Codex/TOML surface (US-008) is a
|
|
605
|
+
* first-class skip until that story lands. Manifests are loaded with a caller-
|
|
606
|
+
* supplied loader so this stays decoupled from `pack-install`'s payload layout and
|
|
607
|
+
* fully testable in isolation.
|
|
608
|
+
*
|
|
609
|
+
* @param pkg the pack name (e.g. `hq-pack-vyg-shopify`) — stamped as provenance
|
|
610
|
+
* @param names the bare server names from `contributes.mcp`
|
|
611
|
+
* @param options manifest loader + secret resolver + injectable env (all optional;
|
|
612
|
+
* without a loader this throws, since US-007 has no payload-dir context)
|
|
613
|
+
* @returns one {@link RegisterServerResult} per server, in `names` order
|
|
614
|
+
*/
|
|
615
|
+
export declare function registerMcpServers(pkg: string, names: string[], options?: {
|
|
616
|
+
/** Load a server's shape-validated manifest by bare name (pack-install supplies this). */
|
|
617
|
+
loadManifest?: (name: string) => McpManifest;
|
|
618
|
+
/** Resolve `${secret:NAME}` at emit (defaults to the no-secret resolver). */
|
|
619
|
+
resolveSecret?: SecretResolver;
|
|
620
|
+
/** Injectable env (tests pass a tmpdir home). */
|
|
621
|
+
env?: Partial<SafeWriteEnv>;
|
|
622
|
+
/** Lock tuning + backup stamp passthrough (tests). */
|
|
623
|
+
lock?: AcquireLockOptions;
|
|
624
|
+
stamp?: string;
|
|
625
|
+
}): RegisterServerResult[];
|
|
626
|
+
/** smol-toml's value-table type (its `parse` return + `stringify` input). */
|
|
627
|
+
type TomlTable = Record<string, unknown>;
|
|
628
|
+
/**
|
|
629
|
+
* The Codex doc threaded through the safe-write core. We carry BOTH the parsed
|
|
630
|
+
* value (`value`, for the merge fn + verify) AND the ORIGINAL file text
|
|
631
|
+
* (`originalText`, `null` for a fresh/empty file). {@link codexConfigFormat.serialize}
|
|
632
|
+
* uses `originalText` to APPEND the one new table without re-serializing — and so
|
|
633
|
+
* without dropping — the user's existing comments and tables. This is the seam that
|
|
634
|
+
* makes "preserves comments" honest within a value-only TOML library.
|
|
635
|
+
*/
|
|
636
|
+
export interface CodexTomlDoc {
|
|
637
|
+
/** The parsed TOML table (the whole `~/.codex/config.toml` as a value). */
|
|
638
|
+
value: TomlTable;
|
|
639
|
+
/** The original file text, or `null` when the file was absent/empty. */
|
|
640
|
+
originalText: string | null;
|
|
641
|
+
}
|
|
642
|
+
/** The Codex `[mcp_servers]` super-table key (the global Codex MCP registry). */
|
|
643
|
+
export declare const CODEX_MCP_SERVERS_KEY = "mcp_servers";
|
|
644
|
+
/**
|
|
645
|
+
* Serialize EXACTLY ONE server table (`[mcp_servers.<name>]` + its sub-tables)
|
|
646
|
+
* by wrapping the def under `mcp_servers.<name>` and round-tripping it through
|
|
647
|
+
* smol-toml. Used both for the append path (preserve-comments) and for the
|
|
648
|
+
* collision/equality comparison. The output is deterministic for a given def so a
|
|
649
|
+
* re-install produces byte-identical text (idempotency depends on it).
|
|
650
|
+
*/
|
|
651
|
+
export declare function serializeOneCodexServer(name: string, def: TomlTable): string;
|
|
652
|
+
/**
|
|
653
|
+
* The Codex `ConfigFormat`. `parse` round-trips the file through smol-toml
|
|
654
|
+
* (throwing `TomlError` on malformed NON-empty input → the substrate maps it to
|
|
655
|
+
* {@link ConfigParseError}, FAIL-CLOSED). `serialize` is comment-preserving: when
|
|
656
|
+
* the doc carries `originalText` (the file pre-existed) it APPENDS the new
|
|
657
|
+
* `mcp_servers.<name>` table to the original bytes rather than re-serializing the
|
|
658
|
+
* whole value; only when there is no original (`null`) does it serialize the full
|
|
659
|
+
* value from scratch. `assertEntry` checks `mcp_servers.<name>` is present.
|
|
660
|
+
*
|
|
661
|
+
* Because `serialize` needs the merged value to know WHICH server to append, the
|
|
662
|
+
* merge fn ({@link mergeCodexServer}) records the just-added server on the doc; the
|
|
663
|
+
* append path reads it back. For an idempotent no-op (server already present,
|
|
664
|
+
* def-equal) the merge returns the doc UNCHANGED with no pending append, so
|
|
665
|
+
* `serialize` reproduces the original text exactly (no write happens).
|
|
666
|
+
*/
|
|
667
|
+
export declare const codexConfigFormat: ConfigFormat<CodexTomlDoc>;
|
|
668
|
+
/**
|
|
669
|
+
* Append a `[mcp_servers.<name>]` table to existing TOML text, preserving the
|
|
670
|
+
* original bytes (and thus all comments + existing tables). Ensures exactly one
|
|
671
|
+
* blank-line separator between the original content and the appended block so the
|
|
672
|
+
* result is well-formed regardless of the original's trailing whitespace.
|
|
673
|
+
*/
|
|
674
|
+
export declare function appendCodexTable(originalText: string, name: string, def: TomlTable): string;
|
|
675
|
+
/**
|
|
676
|
+
* Build the Codex server table emitted as `[mcp_servers.<name>]`: pass the
|
|
677
|
+
* transport fields through, resolve `${secret:}` in every header/env value
|
|
678
|
+
* (recording plaintexts in `secretSink` for redaction), stamp `_hqPack` provenance,
|
|
679
|
+
* and write the per-tool `approval_mode` from the manifest where present (the Codex-
|
|
680
|
+
* specific field — `[mcp_servers.<name>.tools.<t>]` with `approval_mode = "…"`).
|
|
681
|
+
*
|
|
682
|
+
* Key insertion order is deterministic so a re-install serializes byte-identically.
|
|
683
|
+
*/
|
|
684
|
+
export declare function buildCodexServerDef(manifest: McpManifest, pack: string, resolve: SecretResolver, secretSink: Set<string>): TomlTable;
|
|
685
|
+
/**
|
|
686
|
+
* Extract the per-tool `approval_mode` sub-table from a manifest's `tools` map.
|
|
687
|
+
* The manifest `tools` is `Record<tool, { approval_mode?: string, … }>`; we emit
|
|
688
|
+
* `{ <tool>: { approval_mode } }` ONLY for tools that declare an approval_mode
|
|
689
|
+
* (the field the Codex emitter is responsible for). Returns `undefined` when no
|
|
690
|
+
* tool declares one (so no empty tools table is written).
|
|
691
|
+
*/
|
|
692
|
+
export declare function buildCodexToolsTable(tools: McpManifest['tools']): TomlTable | undefined;
|
|
693
|
+
/**
|
|
694
|
+
* The merge fn for the Codex surface: insert/converge `mcp_servers.<name> = def`.
|
|
695
|
+
*
|
|
696
|
+
* - name ABSENT → insert (creating `mcp_servers` if needed) and record a
|
|
697
|
+
* PENDING append so {@link codexConfigFormat.serialize} can preserve comments
|
|
698
|
+
* by appending to the original bytes.
|
|
699
|
+
* - name PRESENT, def-equal (provenance-ignored) → NO-OP (idempotent re-install /
|
|
700
|
+
* PARTIAL self-heal): return the doc UNCHANGED with no pending append, so the
|
|
701
|
+
* original text round-trips byte-for-byte.
|
|
702
|
+
* - name PRESENT, def-DIFFERS → {@link McpNameCollisionError} ABORT (never mutate
|
|
703
|
+
* an existing table — which is also what keeps the comment-preserving append
|
|
704
|
+
* sound: we only ever ADD).
|
|
705
|
+
*
|
|
706
|
+
* Never touches any OTHER server table or any other top-level table.
|
|
707
|
+
*/
|
|
708
|
+
export declare function mergeCodexServer(name: string, def: TomlTable): (doc: CodexTomlDoc) => CodexTomlDoc;
|
|
709
|
+
/**
|
|
710
|
+
* Emit ONE server into the GLOBAL Codex surface (`~/.codex/config.toml`
|
|
711
|
+
* `[mcp_servers.<name>]`) via {@link writeConfigAtomic}. A FIRST-CLASS SKIP when the
|
|
712
|
+
* Codex runtime is absent (`~/.codex` missing — never fabricated). Resolves
|
|
713
|
+
* `${secret:}` headers/env at emit, stamps provenance, writes per-tool
|
|
714
|
+
* `approval_mode`, and is idempotent per surface. Existing tables + sub-tables (and
|
|
715
|
+
* comments) are preserved.
|
|
716
|
+
*
|
|
717
|
+
* @returns the surface result, or a {@link RegisterSurfaceSkip} (Codex absent).
|
|
718
|
+
* @throws McpManifestError invalid server name or unresolvable `${secret:}`
|
|
719
|
+
* @throws McpNameCollisionError the name exists with a different definition
|
|
720
|
+
* @throws ConfigParseError existing config.toml is malformed (fail-closed)
|
|
721
|
+
* @throws ConfigPermissionError / PartialRegistrationError (from the core)
|
|
722
|
+
*/
|
|
723
|
+
export declare function registerCodexServer(opts: RegisterServerOptions): RegisterSurfaceResult | RegisterSurfaceSkip;
|
|
724
|
+
/** Discriminates an un-registration outcome on one surface. */
|
|
725
|
+
export type UnregisterOutcome =
|
|
726
|
+
/** The provenance-stamped entry was found and removed (file changed). */
|
|
727
|
+
'removed'
|
|
728
|
+
/** The entry was already absent / never installed — a clean idempotent no-op. */
|
|
729
|
+
| 'absent'
|
|
730
|
+
/** The entry exists but its provenance does NOT match this pack (foreign / user-created) — left in place. */
|
|
731
|
+
| 'skipped-foreign';
|
|
732
|
+
/** Outcome of un-registering one server from one surface. */
|
|
733
|
+
export interface UnregisterSurfaceResult {
|
|
734
|
+
/** What happened: removed / absent(no-op) / skipped(foreign-or-unstamped). */
|
|
735
|
+
outcome: UnregisterOutcome;
|
|
736
|
+
/** Whether the surface's file actually changed (true only for `removed`). */
|
|
737
|
+
changed: boolean;
|
|
738
|
+
/** The realpath acted on (resolved symlink), when a file was present. */
|
|
739
|
+
realTarget: string;
|
|
740
|
+
/** A human-readable note for the `skipped-foreign` case (suitable for a warning). */
|
|
741
|
+
reason?: string;
|
|
742
|
+
}
|
|
743
|
+
/** A surface deliberately not acted on (e.g. Codex-absent host, or the file never existed). */
|
|
744
|
+
export interface UnregisterSurfaceSkip {
|
|
745
|
+
/** Discriminant: this surface was skipped, not inspected. */
|
|
746
|
+
skipped: true;
|
|
747
|
+
/** Human-readable reason (e.g. "Codex runtime absent"). */
|
|
748
|
+
reason: string;
|
|
749
|
+
}
|
|
750
|
+
/** Outcome of {@link unregisterServer} — one entry per runtime surface. */
|
|
751
|
+
export interface UnregisterServerResult {
|
|
752
|
+
/** The Claude/JSON surface result (always inspected — Claude is always present). */
|
|
753
|
+
claude: UnregisterSurfaceResult;
|
|
754
|
+
/**
|
|
755
|
+
* The Codex/TOML surface result, or a first-class SKIP when the Codex runtime is
|
|
756
|
+
* absent (`~/.codex` missing — never fabricated).
|
|
757
|
+
*/
|
|
758
|
+
codex: UnregisterSurfaceResult | UnregisterSurfaceSkip;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* The merge fn for REMOVING one server from the Claude surface, scoped by
|
|
762
|
+
* provenance. Returns `(doc) => doc'`:
|
|
763
|
+
*
|
|
764
|
+
* - `mcpServers.<name>` ABSENT → return the doc UNCHANGED (idempotent no-op).
|
|
765
|
+
* - present AND `_hqPack === pack` → delete that ONE key from a SHALLOW COPY of
|
|
766
|
+
* `mcpServers`, preserving every sibling server and every other top-level
|
|
767
|
+
* `~/.claude.json` key byte-for-byte. If `mcpServers` becomes empty we LEAVE an
|
|
768
|
+
* empty `mcpServers: {}` (it round-trips cleanly and is idempotent — a second
|
|
769
|
+
* uninstall sees the name already absent and no-ops; we do NOT delete the
|
|
770
|
+
* `mcpServers` key itself so the surface shape stays stable across re-runs).
|
|
771
|
+
* - present AND `_hqPack !== pack` (or unstamped) → return the doc UNCHANGED and
|
|
772
|
+
* do NOT delete (skip-and-warn; the caller reports it). NEVER a bare name-match
|
|
773
|
+
* delete.
|
|
774
|
+
*
|
|
775
|
+
* Never touches any OTHER server entry or any other top-level key.
|
|
776
|
+
*/
|
|
777
|
+
export declare function removeClaudeServer(name: string, pack: string): (doc: Record<string, unknown>) => Record<string, unknown>;
|
|
778
|
+
/**
|
|
779
|
+
* A {@link ConfigFormat} for the Claude REMOVAL path: same parse/serialize/emptyDoc
|
|
780
|
+
* as {@link claudeConfigFormat}, but `assertEntry` is INVERTED — it throws IFF the
|
|
781
|
+
* name is STILL present after the write. {@link writeConfigAtomic} runs this in its
|
|
782
|
+
* verify-after-write step (and in the no-op `nextText === currentText` branch), so a
|
|
783
|
+
* successful removal verifies the entry is GONE rather than present.
|
|
784
|
+
*/
|
|
785
|
+
export declare const claudeRemovalFormat: ConfigFormat<Record<string, unknown>>;
|
|
786
|
+
/** Options for {@link unregisterServer} / {@link unregisterClaudeServer} / {@link unregisterCodexServer}. */
|
|
787
|
+
export interface UnregisterServerOptions {
|
|
788
|
+
/** The bare server name to remove (the `mcpServers` / `mcp_servers` key). */
|
|
789
|
+
name: string;
|
|
790
|
+
/** The pack being uninstalled — ONLY entries stamped with this `_hqPack` are removed. */
|
|
791
|
+
pack: string;
|
|
792
|
+
/** Injectable env (tests pass a tmpdir home so the real configs are untouched). */
|
|
793
|
+
env?: Partial<SafeWriteEnv>;
|
|
794
|
+
/** Lock tuning (tests use tiny timeouts). */
|
|
795
|
+
lock?: AcquireLockOptions;
|
|
796
|
+
/** Backup timestamp override (deterministic tests). */
|
|
797
|
+
stamp?: string;
|
|
798
|
+
/**
|
|
799
|
+
* Append an audit-log line per surface (default true). The AC requires every
|
|
800
|
+
* unregister to log; this exists ONLY to suppress logging in narrow unit tests
|
|
801
|
+
* that call {@link unregisterClaudeServer}/{@link unregisterCodexServer} directly.
|
|
802
|
+
*/
|
|
803
|
+
audit?: boolean;
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* Un-register ONE server from the PINNED single Claude surface (top-level
|
|
807
|
+
* `~/.claude.json` `mcpServers`), provenance-scoped, via {@link writeConfigAtomic}.
|
|
808
|
+
*
|
|
809
|
+
* - stamp matches this pack → REMOVE (through the safe-write path: backup + atomic
|
|
810
|
+
* + lock + verify-ABSENT), `outcome: 'removed'`.
|
|
811
|
+
* - entry absent → clean no-op, `outcome: 'absent'` (no write).
|
|
812
|
+
* - entry present but foreign/unstamped → SKIP-AND-WARN, `outcome: 'skipped-foreign'`
|
|
813
|
+
* (no write; the caller surfaces `reason`).
|
|
814
|
+
*
|
|
815
|
+
* Tolerates an absent / blank `~/.claude.json` (treated as "nothing to remove" →
|
|
816
|
+
* `absent`). Idempotent: a second uninstall of the same server sees it already gone
|
|
817
|
+
* → `absent`.
|
|
818
|
+
*
|
|
819
|
+
* @throws ConfigParseError existing non-empty `~/.claude.json` is unparseable (ABORT)
|
|
820
|
+
* @throws ConfigPermissionError read/lock failure other than ENOENT (ABORT)
|
|
821
|
+
* @throws PartialRegistrationError verify-after-write found the entry STILL present
|
|
822
|
+
*/
|
|
823
|
+
export declare function unregisterClaudeServer(opts: UnregisterServerOptions): UnregisterSurfaceResult;
|
|
824
|
+
/**
|
|
825
|
+
* The merge fn for REMOVING one server from the Codex surface, scoped by provenance.
|
|
826
|
+
*
|
|
827
|
+
* - `mcp_servers.<name>` ABSENT → return the doc UNCHANGED with NO pending removal,
|
|
828
|
+
* so {@link codexRemovalFormat.serialize} reproduces `originalText` byte-for-byte
|
|
829
|
+
* (no write, comments intact) — idempotent no-op.
|
|
830
|
+
* - present AND `_hqPack === pack` → drop the key from a copy of the parsed `value`
|
|
831
|
+
* AND record a PENDING_REMOVE marker so `serialize` knows it must RE-SERIALIZE the
|
|
832
|
+
* value (the append-only path cannot express a removal). Sibling tables and every
|
|
833
|
+
* `.tools.<t>.approval_mode` sub-table survive (they live in the re-serialized
|
|
834
|
+
* value); COMMENTS are dropped on this write (documented value-only-TOML limit).
|
|
835
|
+
* - present AND `_hqPack !== pack` (or unstamped) → return the doc UNCHANGED, no
|
|
836
|
+
* pending removal (skip-and-warn). NEVER a bare name-match delete.
|
|
837
|
+
*
|
|
838
|
+
* Never touches any OTHER server table or any other top-level table.
|
|
839
|
+
*/
|
|
840
|
+
export declare function removeCodexServer(name: string, pack: string): (doc: CodexTomlDoc) => CodexTomlDoc;
|
|
841
|
+
/**
|
|
842
|
+
* A {@link ConfigFormat} for the Codex REMOVAL path. It REUSES `parse`/`emptyDoc`
|
|
843
|
+
* from {@link codexConfigFormat} but supplies its OWN `serialize` + an INVERTED
|
|
844
|
+
* `assertEntry`:
|
|
845
|
+
*
|
|
846
|
+
* - `serialize`: when a PENDING_REMOVE is present the key was actually dropped, so
|
|
847
|
+
* we RE-SERIALIZE the modified `value` via smol-toml `stringifyToml` (this is the
|
|
848
|
+
* only way to express a removal; it DROPS comments — documented). With NO pending
|
|
849
|
+
* removal (no-op / foreign) we reproduce `originalText` byte-for-byte (or
|
|
850
|
+
* `stringifyToml(value)` for a fresh doc with no original), so an unchanged doc
|
|
851
|
+
* round-trips and `writeConfigAtomic` writes nothing.
|
|
852
|
+
* - `assertEntry`: throws IFF `mcp_servers.<name>` is STILL present (verify-ABSENT).
|
|
853
|
+
*/
|
|
854
|
+
export declare const codexRemovalFormat: ConfigFormat<CodexTomlDoc>;
|
|
855
|
+
/**
|
|
856
|
+
* Un-register ONE server from the GLOBAL Codex surface (`~/.codex/config.toml`
|
|
857
|
+
* `[mcp_servers.<name>]`), provenance-scoped, via {@link writeConfigAtomic}. A
|
|
858
|
+
* FIRST-CLASS SKIP when the Codex runtime is absent (`~/.codex` missing — never
|
|
859
|
+
* fabricated): nothing to un-register, no crash.
|
|
860
|
+
*
|
|
861
|
+
* - stamp matches this pack → REMOVE (safe-write path; verify-ABSENT). The removal
|
|
862
|
+
* write RE-SERIALIZES the value, which DROPS comments (documented limitation) but
|
|
863
|
+
* preserves sibling tables + `.tools.<t>.approval_mode` sub-tables.
|
|
864
|
+
* - entry absent → clean no-op, `outcome: 'absent'` (no write; comments intact).
|
|
865
|
+
* - entry present but foreign/unstamped → SKIP-AND-WARN, `outcome: 'skipped-foreign'`.
|
|
866
|
+
*
|
|
867
|
+
* @returns the surface result, or a {@link UnregisterSurfaceSkip} (Codex absent).
|
|
868
|
+
* @throws ConfigParseError existing config.toml is malformed (fail-closed)
|
|
869
|
+
* @throws ConfigPermissionError / PartialRegistrationError (from the core)
|
|
870
|
+
*/
|
|
871
|
+
export declare function unregisterCodexServer(opts: UnregisterServerOptions): UnregisterSurfaceResult | UnregisterSurfaceSkip;
|
|
872
|
+
/**
|
|
873
|
+
* Fan ONE server's un-registration out across BOTH per-runtime surfaces — the mirror
|
|
874
|
+
* of {@link registerServer}. Claude is inspected first (consistent with register's
|
|
875
|
+
* ordering, though for removal the ordering is less critical: each surface is
|
|
876
|
+
* independent and idempotent). The Codex arm is a FIRST-CLASS SKIP when `~/.codex` is
|
|
877
|
+
* absent. Each arm is provenance-scoped and skip-and-warns on a foreign entry —
|
|
878
|
+
* neither surface aborts the other.
|
|
879
|
+
*/
|
|
880
|
+
export declare function unregisterServer(opts: UnregisterServerOptions): UnregisterServerResult;
|
|
881
|
+
/**
|
|
882
|
+
* Un-register one pack's MCP servers from the shared agent configs — the public seam
|
|
883
|
+
* the UNINSTALL path calls, mirroring {@link registerMcpServers}. Unlike register, it
|
|
884
|
+
* needs NO manifest loader: removal keys off the server NAME + the pack's PROVENANCE
|
|
885
|
+
* stamp on the on-disk entry, not the manifest. So the signature is simpler:
|
|
886
|
+
* `(pkg, names, { env?, lock?, stamp? })`.
|
|
887
|
+
*
|
|
888
|
+
* For each name it fans out via {@link unregisterServer} (Claude + Codex), removing
|
|
889
|
+
* ONLY entries stamped `_hqPack === pkg` and skip-and-warning on any foreign/unstamped
|
|
890
|
+
* same-named entry. Tolerant of an absent Codex runtime / absent config files, and
|
|
891
|
+
* idempotent (uninstalling an already-removed pack is a clean no-op).
|
|
892
|
+
*
|
|
893
|
+
* @param pkg the pack name being uninstalled (the provenance to match)
|
|
894
|
+
* @param names the bare server names from `contributes.mcp`
|
|
895
|
+
* @returns one {@link UnregisterServerResult} per server, in `names` order
|
|
896
|
+
*/
|
|
897
|
+
export declare function unregisterMcpServers(pkg: string, names: string[], options?: {
|
|
898
|
+
/** Injectable env (tests pass a tmpdir home). */
|
|
899
|
+
env?: Partial<SafeWriteEnv>;
|
|
900
|
+
/** Lock tuning + backup stamp passthrough (tests). */
|
|
901
|
+
lock?: AcquireLockOptions;
|
|
902
|
+
stamp?: string;
|
|
903
|
+
}): UnregisterServerResult[];
|
|
904
|
+
export {};
|
|
905
|
+
//# sourceMappingURL=mcp-registration.d.ts.map
|