plugnz 0.0.4 → 0.0.6
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/plugnz.mjs +20000 -0
- package/package.json +13 -10
- package/bin/plgnz.mjs +0 -3
- package/bin/plugnz.mjs +0 -5
- package/src/cli.ts +0 -489
- package/src/compatibility.ts +0 -23
- package/src/consumer-profiles.ts +0 -77
- package/src/conversion.ts +0 -544
- package/src/doctor.ts +0 -277
- package/src/exec.ts +0 -91
- package/src/fingerprint.ts +0 -77
- package/src/globals.d.ts +0 -97
- package/src/hermes-identity.ts +0 -4
- package/src/host.ts +0 -131
- package/src/hosts/claude-code-writer.ts +0 -589
- package/src/hosts/claude-code.ts +0 -124
- package/src/hosts/codex-writer.ts +0 -418
- package/src/hosts/codex.ts +0 -183
- package/src/hosts/cursor-writer.ts +0 -279
- package/src/hosts/cursor.ts +0 -107
- package/src/hosts/dcode-writer.ts +0 -150
- package/src/hosts/dcode.ts +0 -74
- package/src/hosts/grok-writer.ts +0 -254
- package/src/hosts/grok.ts +0 -94
- package/src/hosts/hermes-writer.ts +0 -223
- package/src/hosts/hermes.ts +0 -88
- package/src/hosts/index.ts +0 -25
- package/src/hosts/kimi-writer.ts +0 -238
- package/src/hosts/kimi.ts +0 -140
- package/src/hosts/omp-writer.ts +0 -179
- package/src/hosts/omp.ts +0 -153
- package/src/hosts/opencode-writer.ts +0 -112
- package/src/hosts/opencode.ts +0 -38
- package/src/hosts/pi-writer.ts +0 -111
- package/src/hosts/pi.ts +0 -48
- package/src/hosts/writers.ts +0 -23
- package/src/hosts/zcode-cli-writer.ts +0 -239
- package/src/hosts/zcode-cli.ts +0 -195
- package/src/mcp-write.ts +0 -81
- package/src/mcp.ts +0 -161
- package/src/paths.ts +0 -101
- package/src/pin.ts +0 -135
- package/src/source.ts +0 -224
- package/src/state-write.ts +0 -25
- package/src/state.ts +0 -118
- package/src/update.ts +0 -230
package/src/doctor.ts
DELETED
|
@@ -1,277 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `plgnz doctor` — read-only diagnosis.
|
|
3
|
-
*
|
|
4
|
-
* Read-only by construction: this module imports only the readers-only host
|
|
5
|
-
* registry (src/hosts/index.ts) — no writer module loads with it — and
|
|
6
|
-
* touches no file except reading configs and running `git rev-parse` /
|
|
7
|
-
* `git ls-remote` against recorded sources. test/doctor-imports.test.ts
|
|
8
|
-
* walks this module's import graph and fails if a writer appears in it.
|
|
9
|
-
*
|
|
10
|
-
* Three checks (AGENTS.md contract):
|
|
11
|
-
* (1) command resolution — every stdio server in each host's native MCP
|
|
12
|
-
* config AND in each installed plugin's mcp.json/.mcp.json: a command
|
|
13
|
-
* containing `/` must exist and be executable; a bare command is
|
|
14
|
-
* resolved on this process's PATH. On GUI hosts (cursor) a bare command
|
|
15
|
-
* is additionally flagged `!` with the hint `run plgnz pin` —
|
|
16
|
-
* macOS GUI apps have no shell PATH (spec §7.2.1 makes PATH
|
|
17
|
-
* participation client-defined).
|
|
18
|
-
* (2) shadow / duplicate — the same server name in a host's user-level MCP
|
|
19
|
-
* config and in an installed plugin's mcp.json is ✗: on codex the user
|
|
20
|
-
* entry wins and silently shadows the plugin server; on cursor both
|
|
21
|
-
* load and duplicate.
|
|
22
|
-
* (3) staleness — the installed plugin's recorded source sha (state.json,
|
|
23
|
-
* written by `add`) vs the source's current head: `git rev-parse` for a
|
|
24
|
-
* local checkout, `git ls-remote` for a git URL (recorded verbatim by
|
|
25
|
-
* `add`, so it is the same query the install was resolved with).
|
|
26
|
-
* Installs made by another tool have no record: report `unknown` —
|
|
27
|
-
* never treat a missing record as fresh or as zero.
|
|
28
|
-
*
|
|
29
|
-
* Output: one line per finding `<host> ✓|✗|! <message>`; `--json` emits
|
|
30
|
-
* the same as an array; exit 1 if any ✗, else 0.
|
|
31
|
-
*
|
|
32
|
-
* Messages never include server args or env values — args can carry
|
|
33
|
-
* credentials.
|
|
34
|
-
*/
|
|
35
|
-
import type { HostReader, McpServerEntry } from './host';
|
|
36
|
-
import { hosts as allHosts } from './hosts';
|
|
37
|
-
import { findRecord, readState, type InstallRecord } from './state';
|
|
38
|
-
import { expandRootPlaceholders, gitHead, gitRemoteHead, isExecutableFile, isGitUrl, resolveCommandPath, which } from './exec';
|
|
39
|
-
import { fingerprintInstallation, fingerprintTree } from './fingerprint';
|
|
40
|
-
|
|
41
|
-
export type Mark = '✓' | '✗' | '!';
|
|
42
|
-
|
|
43
|
-
export interface DoctorFinding {
|
|
44
|
-
host: string;
|
|
45
|
-
mark: Mark;
|
|
46
|
-
message: string;
|
|
47
|
-
check?: 'content';
|
|
48
|
-
pluginId?: string;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function contentFinding(host: string, pluginId: string, mark: Mark, message: string): DoctorFinding {
|
|
52
|
-
return { host, pluginId, check: 'content', mark, message };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/** Verify source bytes and the activated native tree independently of git freshness. */
|
|
56
|
-
function checkContent(host: HostReader, state: InstallRecord[], out: DoctorFinding[]): void {
|
|
57
|
-
const installed = new Map(host.listInstalled().map((plugin) => [plugin.id, plugin]));
|
|
58
|
-
const records = state.filter((record) => record.host === host.id);
|
|
59
|
-
for (const record of records) {
|
|
60
|
-
if (record.pending !== undefined) {
|
|
61
|
-
out.push(contentFinding(host.id, record.id, '!', `install '${record.id}' content unverified — ${record.pending} is pending`));
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
|
-
const native = installed.get(record.id);
|
|
65
|
-
if (native === undefined || native.enabled === false || native.path === undefined) {
|
|
66
|
-
out.push(contentFinding(host.id, record.id, '✗', `install '${record.id}' content stale — enabled native representation is missing`));
|
|
67
|
-
continue;
|
|
68
|
-
}
|
|
69
|
-
if (record.sourceDir === undefined || record.fingerprint === undefined || record.installedFingerprint === undefined) {
|
|
70
|
-
out.push(contentFinding(host.id, record.id, '!', `install '${record.id}' content unverified — legacy record has no complete byte proof`));
|
|
71
|
-
continue;
|
|
72
|
-
}
|
|
73
|
-
let sourceFingerprint: string;
|
|
74
|
-
try { sourceFingerprint = fingerprintTree(record.sourceDir); }
|
|
75
|
-
catch (error) {
|
|
76
|
-
out.push(contentFinding(host.id, record.id, '✗', `install '${record.id}' source content cannot be verified — ${(error as Error).message}`));
|
|
77
|
-
continue;
|
|
78
|
-
}
|
|
79
|
-
if (sourceFingerprint !== record.fingerprint) {
|
|
80
|
-
out.push(contentFinding(host.id, record.id, '✗', `install '${record.id}' source content changed since activation`));
|
|
81
|
-
continue;
|
|
82
|
-
}
|
|
83
|
-
let nativeFingerprint: string;
|
|
84
|
-
try { nativeFingerprint = fingerprintInstallation(native); }
|
|
85
|
-
catch (error) {
|
|
86
|
-
out.push(contentFinding(host.id, record.id, '✗', `install '${record.id}' native content cannot be verified — ${(error as Error).message}`));
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
if (nativeFingerprint !== record.installedFingerprint) {
|
|
90
|
-
out.push(contentFinding(host.id, record.id, '✗', `install '${record.id}' native content changed after activation`));
|
|
91
|
-
continue;
|
|
92
|
-
}
|
|
93
|
-
out.push(contentFinding(host.id, record.id, '✓', `install '${record.id}' source and native content match their recorded byte proofs`));
|
|
94
|
-
}
|
|
95
|
-
for (const native of installed.values()) {
|
|
96
|
-
if (records.some((record) => record.id === native.id)) continue;
|
|
97
|
-
out.push(contentFinding(host.id, native.id, '!', `install '${native.id}' content unverified — no plgnz record`));
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export interface DoctorResult {
|
|
102
|
-
findings: DoctorFinding[];
|
|
103
|
-
exitCode: number;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function originLabel(entry: McpServerEntry): string {
|
|
107
|
-
if (entry.origin === 'plugin' && entry.pluginId !== undefined) return `plugin ${entry.pluginId}`;
|
|
108
|
-
if (entry.origin === 'plugin') return 'plugin';
|
|
109
|
-
return 'user config';
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function shortSha(sha: string): string {
|
|
113
|
-
return sha.slice(0, 8);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** sha equality tolerant of the host recording a short sha (claude-code records 12 chars). */
|
|
117
|
-
function sameSha(installed: string, head: string): boolean {
|
|
118
|
-
const a = installed.toLowerCase();
|
|
119
|
-
const b = head.toLowerCase();
|
|
120
|
-
const n = Math.min(a.length, b.length);
|
|
121
|
-
return a.slice(0, n) === b.slice(0, n);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Check (1): every stdio command resolves and is executable. */
|
|
125
|
-
function checkCommands(host: HostReader, entries: McpServerEntry[], out: DoctorFinding[]): void {
|
|
126
|
-
for (const entry of entries) {
|
|
127
|
-
if (entry.transport !== 'stdio' || entry.command === undefined) continue;
|
|
128
|
-
const label = originLabel(entry);
|
|
129
|
-
const suffix = entry.enabled === false ? ' [disabled]' : '';
|
|
130
|
-
// Host-native configs expand root placeholders at launch (spec §9.2 for
|
|
131
|
-
// ${PLUGIN_ROOT}; CLAUDE_/CODEX_/CURSOR_ variants measured in real caches).
|
|
132
|
-
// A known placeholder is expanded here so the real target is checked; an
|
|
133
|
-
// unknown one makes the entry unverifiable — flagged `!`, not dead.
|
|
134
|
-
let command = entry.command;
|
|
135
|
-
if (command.includes('${')) {
|
|
136
|
-
const result = expandRootPlaceholders(command, entry.baseDir);
|
|
137
|
-
if (result.unknown !== null) {
|
|
138
|
-
out.push({
|
|
139
|
-
host: host.id,
|
|
140
|
-
mark: '!',
|
|
141
|
-
message: `server '${entry.name}': command contains placeholder ${result.unknown} that plgnz cannot expand — not verified (${label})${suffix}`,
|
|
142
|
-
});
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
command = result.expanded;
|
|
146
|
-
}
|
|
147
|
-
if (command.includes('/')) {
|
|
148
|
-
const resolved = resolveCommandPath(command, entry.baseDir);
|
|
149
|
-
if (isExecutableFile(resolved)) {
|
|
150
|
-
out.push({ host: host.id, mark: '✓', message: `server '${entry.name}': command ok: ${resolved} (${label})${suffix}` });
|
|
151
|
-
} else {
|
|
152
|
-
out.push({
|
|
153
|
-
host: host.id,
|
|
154
|
-
mark: '✗',
|
|
155
|
-
message: `server '${entry.name}': command not found or not executable: ${resolved} (${label})${suffix}`,
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
} else {
|
|
159
|
-
const resolved = which(entry.command);
|
|
160
|
-
if (host.gui) {
|
|
161
|
-
if (resolved === null) {
|
|
162
|
-
out.push({
|
|
163
|
-
host: host.id,
|
|
164
|
-
mark: '✗',
|
|
165
|
-
message: `server '${entry.name}': bare command '${entry.command}' not found on PATH (${label})${suffix}`,
|
|
166
|
-
});
|
|
167
|
-
} else {
|
|
168
|
-
out.push({
|
|
169
|
-
host: host.id,
|
|
170
|
-
mark: '!',
|
|
171
|
-
message:
|
|
172
|
-
`server '${entry.name}': bare command '${entry.command}' may not resolve — macOS GUI apps have no shell PATH ` +
|
|
173
|
-
`(spec §7.2.1); run plgnz pin${suffix}`,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
} else if (resolved !== null) {
|
|
177
|
-
out.push({
|
|
178
|
-
host: host.id,
|
|
179
|
-
mark: '✓',
|
|
180
|
-
message: `server '${entry.name}': bare command '${entry.command}' resolves to ${resolved} (${label})${suffix}`,
|
|
181
|
-
});
|
|
182
|
-
} else {
|
|
183
|
-
out.push({
|
|
184
|
-
host: host.id,
|
|
185
|
-
mark: '✗',
|
|
186
|
-
message: `server '${entry.name}': bare command '${entry.command}' not found on PATH (${label})${suffix}`,
|
|
187
|
-
});
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Check (2): a server name defined both user-level and by an installed plugin. */
|
|
194
|
-
function checkShadows(host: HostReader, entries: McpServerEntry[], out: DoctorFinding[]): void {
|
|
195
|
-
const userNames = new Set(entries.filter((e) => e.origin === 'user').map((e) => e.name));
|
|
196
|
-
const seen = new Set<string>();
|
|
197
|
-
for (const entry of entries) {
|
|
198
|
-
if (entry.origin !== 'plugin' || entry.pluginId === undefined || !userNames.has(entry.name)) continue;
|
|
199
|
-
const key = `${entry.name}\0${entry.pluginId}`;
|
|
200
|
-
if (seen.has(key)) continue;
|
|
201
|
-
seen.add(key);
|
|
202
|
-
let behavior: string;
|
|
203
|
-
if (host.id === 'codex') {
|
|
204
|
-
behavior = 'on codex the user entry wins and silently shadows the plugin server';
|
|
205
|
-
} else if (host.id === 'cursor') {
|
|
206
|
-
behavior = 'on cursor both load and duplicate';
|
|
207
|
-
} else {
|
|
208
|
-
behavior = 'they shadow or duplicate each other';
|
|
209
|
-
}
|
|
210
|
-
out.push({
|
|
211
|
-
host: host.id,
|
|
212
|
-
mark: '✗',
|
|
213
|
-
message: `server '${entry.name}' is defined both in user config and in plugin '${entry.pluginId}' — ${behavior}`,
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** Check (3): installed sha (state.json) vs the source's current head. */
|
|
219
|
-
function checkStaleness(host: HostReader, state: InstallRecord[], out: DoctorFinding[]): void {
|
|
220
|
-
for (const install of host.listInstalled()) {
|
|
221
|
-
const record = findRecord(state, host.id, install.id);
|
|
222
|
-
if (record === undefined) {
|
|
223
|
-
out.push({
|
|
224
|
-
host: host.id,
|
|
225
|
-
mark: '!',
|
|
226
|
-
message: `install '${install.id}' staleness unknown — no record in state.json (installed by another tool?)`,
|
|
227
|
-
});
|
|
228
|
-
continue;
|
|
229
|
-
}
|
|
230
|
-
if (record.pending !== undefined) continue;
|
|
231
|
-
// A git-URL source is recorded verbatim by `add` and has no local
|
|
232
|
-
// checkout to rev-parse — its head comes from `git ls-remote`, the same
|
|
233
|
-
// query that resolved the install (src/source.ts).
|
|
234
|
-
const head = isGitUrl(record.source) ? gitRemoteHead(record.source) : gitHead(record.source);
|
|
235
|
-
if (head === null) {
|
|
236
|
-
out.push({
|
|
237
|
-
host: host.id,
|
|
238
|
-
mark: '!',
|
|
239
|
-
message: `install '${install.id}' staleness unknown — cannot determine head of ${record.source}`,
|
|
240
|
-
});
|
|
241
|
-
continue;
|
|
242
|
-
}
|
|
243
|
-
if (sameSha(record.sourceSha, head)) {
|
|
244
|
-
out.push({ host: host.id, mark: '✓', message: `install '${install.id}' is at source head ${shortSha(head)}` });
|
|
245
|
-
} else {
|
|
246
|
-
out.push({
|
|
247
|
-
host: host.id,
|
|
248
|
-
mark: '✗',
|
|
249
|
-
message: `install '${install.id}' is stale — installed ${shortSha(record.sourceSha)}, source head ${shortSha(head)}`,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
|
-
export function runDoctor(hostList: HostReader[] = allHosts, state: InstallRecord[] = readState()): DoctorResult {
|
|
256
|
-
const findings: DoctorFinding[] = [];
|
|
257
|
-
const selected = new Set(hostList.map((host) => host.id));
|
|
258
|
-
for (const record of state) {
|
|
259
|
-
if (record.pending !== undefined && selected.has(record.host)) {
|
|
260
|
-
findings.push({ host: record.host, mark: '!', message: `install '${record.id}' has pending ${record.pending} intent — retry the ${record.pending === 'remove' ? 'remove' : 'add/update'} operation` });
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
for (const host of hostList) {
|
|
264
|
-
if (!host.detect()) continue;
|
|
265
|
-
const entries = host.mcpEntries();
|
|
266
|
-
checkCommands(host, entries, findings);
|
|
267
|
-
checkShadows(host, entries, findings);
|
|
268
|
-
checkContent(host, state, findings);
|
|
269
|
-
checkStaleness(host, state, findings);
|
|
270
|
-
}
|
|
271
|
-
const exitCode = findings.some((f) => f.mark === '✗') ? 1 : 0;
|
|
272
|
-
return { findings, exitCode };
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
export function formatFinding(f: DoctorFinding): string {
|
|
276
|
-
return `${f.host} ${f.mark} ${f.message}`;
|
|
277
|
-
}
|
package/src/exec.ts
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Command-resolution and git helpers shared by doctor.
|
|
3
|
-
*/
|
|
4
|
-
import { accessSync, constants, statSync } from 'node:fs';
|
|
5
|
-
import { isAbsolute, join } from 'node:path';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
7
|
-
|
|
8
|
-
/** A path that exists and is an executable regular file. */
|
|
9
|
-
export function isExecutableFile(p: string): boolean {
|
|
10
|
-
try {
|
|
11
|
-
if (!statSync(p).isFile()) return false;
|
|
12
|
-
accessSync(p, constants.X_OK);
|
|
13
|
-
return true;
|
|
14
|
-
} catch {
|
|
15
|
-
return false;
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Resolve a bare command on *this process's* PATH; null when not found. */
|
|
20
|
-
export function which(command: string): string | null {
|
|
21
|
-
const pathEnv = process.env['PATH'] ?? '';
|
|
22
|
-
for (const dir of pathEnv.split(':')) {
|
|
23
|
-
if (dir.length === 0) continue;
|
|
24
|
-
const candidate = join(dir, command);
|
|
25
|
-
if (isExecutableFile(candidate)) return candidate;
|
|
26
|
-
}
|
|
27
|
-
return null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Resolve a command containing `/` (spec §7.2.1 allows bare names or
|
|
32
|
-
* plugin-relative `./` paths). Absolute paths pass through; relative paths
|
|
33
|
-
* resolve against `baseDir` (the plugin root, or the config file's root).
|
|
34
|
-
*/
|
|
35
|
-
export function resolveCommandPath(command: string, baseDir: string): string {
|
|
36
|
-
return isAbsolute(command) ? command : join(baseDir, command);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Root placeholders hosts expand at launch: spec §9.2 defines `${PLUGIN_ROOT}`;
|
|
41
|
-
* the CLAUDE_/CODEX_/CURSOR_ variants are host-native conventions measured in
|
|
42
|
-
* real caches (e.g. `${CODEX_PLUGIN_ROOT}/scripts/air-launcher.sh`). spec
|
|
43
|
-
* §7.2.1 forbids placeholder expansion in a spec `command`, so these only
|
|
44
|
-
* occur in host-native config files. Unknown placeholders are reported back
|
|
45
|
-
* so doctor can mark the entry unverifiable instead of dead.
|
|
46
|
-
*/
|
|
47
|
-
const ROOT_PLACEHOLDERS = new Set(['PLUGIN_ROOT', 'CLAUDE_PLUGIN_ROOT', 'CODEX_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT']);
|
|
48
|
-
|
|
49
|
-
export function expandRootPlaceholders(command: string, root: string): { expanded: string; unknown: string | null } {
|
|
50
|
-
let unknown: string | null = null;
|
|
51
|
-
const expanded = command.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (whole, name: string) => {
|
|
52
|
-
if (ROOT_PLACEHOLDERS.has(name)) return root;
|
|
53
|
-
unknown = whole;
|
|
54
|
-
return whole;
|
|
55
|
-
});
|
|
56
|
-
return { expanded, unknown };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Current HEAD sha of a local git checkout, or null when unavailable. */
|
|
60
|
-
export function gitHead(dir: string): string | null {
|
|
61
|
-
try {
|
|
62
|
-
const r = spawnSync('git', ['-C', dir, 'rev-parse', 'HEAD'], { encoding: 'utf8' });
|
|
63
|
-
if (r.status !== 0) return null;
|
|
64
|
-
const sha = r.stdout.trim();
|
|
65
|
-
return /^[0-9a-f]{7,40}$/i.test(sha) ? sha : null;
|
|
66
|
-
} catch {
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* A remote git source — the URL prefixes `add` accepts (src/source.ts
|
|
73
|
-
* `resolveSource`). One predicate shared by `add` and doctor so the source
|
|
74
|
-
* kinds that get recorded and the source kinds doctor can freshness-check
|
|
75
|
-
* cannot drift apart.
|
|
76
|
-
*/
|
|
77
|
-
export function isGitUrl(source: string): boolean {
|
|
78
|
-
return source.startsWith('http://') || source.startsWith('https://') || source.startsWith('git@');
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Current HEAD sha of a git remote via `git ls-remote`, or null when unreachable. */
|
|
82
|
-
export function gitRemoteHead(url: string): string | null {
|
|
83
|
-
try {
|
|
84
|
-
const r = spawnSync('git', ['ls-remote', url, 'HEAD'], { encoding: 'utf8' });
|
|
85
|
-
if (r.status !== 0) return null;
|
|
86
|
-
const sha = r.stdout.split('\t')[0]?.trim() ?? '';
|
|
87
|
-
return /^[0-9a-f]{40}$/i.test(sha) ? sha : null;
|
|
88
|
-
} catch {
|
|
89
|
-
return null;
|
|
90
|
-
}
|
|
91
|
-
}
|
package/src/fingerprint.ts
DELETED
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
/** Stable raw-byte tree fingerprint shared by source capture and read-only verification. */
|
|
2
|
-
import { lstatSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
-
import { isAbsolute, join } from 'node:path';
|
|
4
|
-
import type { InstalledPlugin } from './host';
|
|
5
|
-
|
|
6
|
-
declare const Bun: {
|
|
7
|
-
CryptoHasher: new (algorithm: 'sha256') => {
|
|
8
|
-
update(input: string | Uint8Array): void;
|
|
9
|
-
digest(encoding: 'hex'): string;
|
|
10
|
-
};
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
declare const TextEncoder: {
|
|
14
|
-
new (): { encode(input?: string): Uint8Array };
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
export function fingerprintTree(root: string): string {
|
|
18
|
-
if (lstatSync(root).isSymbolicLink()) throw new Error('cannot fingerprint symlink: .');
|
|
19
|
-
const hash = new Bun.CryptoHasher('sha256');
|
|
20
|
-
const walk = (dir: string, prefix: string): void => {
|
|
21
|
-
for (const entry of readdirSync(dir).sort()) {
|
|
22
|
-
const path = join(dir, entry);
|
|
23
|
-
const relative = prefix === '' ? entry : `${prefix}/${entry}`;
|
|
24
|
-
const stat = lstatSync(path);
|
|
25
|
-
if (stat.isSymbolicLink()) throw new Error(`cannot fingerprint symlink: ${relative}`);
|
|
26
|
-
if (stat.isDirectory()) {
|
|
27
|
-
hash.update(`d\0${relative}\0`);
|
|
28
|
-
walk(path, relative);
|
|
29
|
-
} else if (stat.isFile()) {
|
|
30
|
-
hash.update(`f\0${relative}\0`);
|
|
31
|
-
const content = new Bun.CryptoHasher('sha256');
|
|
32
|
-
content.update(readBytes(path));
|
|
33
|
-
hash.update(content.digest('hex'));
|
|
34
|
-
hash.update('\0');
|
|
35
|
-
} else {
|
|
36
|
-
throw new Error(`cannot fingerprint non-file resource: ${relative}`);
|
|
37
|
-
}
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
walk(root, '');
|
|
41
|
-
return hash.digest('hex');
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Fingerprint the bytes that make an installation active for its host.
|
|
46
|
-
* Legacy hosts expose one native tree through `path`; multi-root hosts label
|
|
47
|
-
* every active root so their separate stores cannot silently escape proof.
|
|
48
|
-
*/
|
|
49
|
-
export function fingerprintInstallation(plugin: InstalledPlugin): string {
|
|
50
|
-
if (plugin.contentRoots === undefined) {
|
|
51
|
-
if (plugin.path === undefined) throw new Error('cannot fingerprint installation without a native path');
|
|
52
|
-
return fingerprintTree(plugin.path);
|
|
53
|
-
}
|
|
54
|
-
const roots = Object.entries(plugin.contentRoots).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
55
|
-
if (roots.length === 0) throw new Error('cannot fingerprint installation with no content roots');
|
|
56
|
-
const hash = new Bun.CryptoHasher('sha256');
|
|
57
|
-
for (const [label, root] of roots) {
|
|
58
|
-
if (label.length === 0) throw new Error('cannot fingerprint installation with an empty content root label');
|
|
59
|
-
if (!isAbsolute(root)) throw new Error(`content root '${label}' is not absolute: ${root}`);
|
|
60
|
-
const fingerprint = fingerprintTree(root);
|
|
61
|
-
frame(hash, label);
|
|
62
|
-
frame(hash, fingerprint);
|
|
63
|
-
}
|
|
64
|
-
return hash.digest('hex');
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
function frame(hash: InstanceType<typeof Bun.CryptoHasher>, value: string): void {
|
|
68
|
-
const bytes = new TextEncoder().encode(value);
|
|
69
|
-
const size = new Uint8Array([bytes.byteLength >>> 24, bytes.byteLength >>> 16, bytes.byteLength >>> 8, bytes.byteLength]);
|
|
70
|
-
hash.update(size);
|
|
71
|
-
hash.update(bytes);
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function readBytes(path: string): Uint8Array {
|
|
75
|
-
const read = readFileSync as unknown as (file: string) => Uint8Array;
|
|
76
|
-
return read(path);
|
|
77
|
-
}
|
package/src/globals.d.ts
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Minimal ambient declarations for the node builtins and globals this project
|
|
3
|
-
* uses, so `tsc --noEmit` passes under strict mode with a zero-dependency
|
|
4
|
-
* typecheck (allowed deps: typescript, smol-toml, add-mcp — no @types/node).
|
|
5
|
-
* Bun implements these at runtime; only the surface we consume is declared.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
declare module 'node:fs' {
|
|
9
|
-
export function existsSync(path: string): boolean;
|
|
10
|
-
export function readFileSync(path: string, encoding: 'utf8'): string;
|
|
11
|
-
export function readdirSync(path: string): string[];
|
|
12
|
-
export function statSync(path: string): {
|
|
13
|
-
isFile(): boolean;
|
|
14
|
-
isDirectory(): boolean;
|
|
15
|
-
};
|
|
16
|
-
export function lstatSync(path: string): {
|
|
17
|
-
isSymbolicLink(): boolean;
|
|
18
|
-
isFile(): boolean;
|
|
19
|
-
isDirectory(): boolean;
|
|
20
|
-
};
|
|
21
|
-
export function accessSync(path: string, mode?: number): void;
|
|
22
|
-
export function mkdirSync(path: string, options?: { recursive?: boolean }): string | undefined;
|
|
23
|
-
export function rmSync(path: string, options?: { recursive?: boolean; force?: boolean }): void;
|
|
24
|
-
export function cpSync(src: string, dest: string, options?: { recursive?: boolean }): void;
|
|
25
|
-
export function writeFileSync(path: string, data: string): void;
|
|
26
|
-
export function chmodSync(path: string, mode: number): void;
|
|
27
|
-
export function mkdtempSync(prefix: string): string;
|
|
28
|
-
export function renameSync(oldPath: string, newPath: string): void;
|
|
29
|
-
export function realpathSync(path: string): string;
|
|
30
|
-
export function symlinkSync(target: string, path: string, type?: 'dir' | 'file' | 'junction'): void;
|
|
31
|
-
export function readlinkSync(path: string): string;
|
|
32
|
-
export const constants: { X_OK: number };
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
declare module 'node:path' {
|
|
36
|
-
export function join(...parts: string[]): string;
|
|
37
|
-
export function resolve(...parts: string[]): string;
|
|
38
|
-
export function relative(from: string, to: string): string;
|
|
39
|
-
export function dirname(p: string): string;
|
|
40
|
-
export function basename(p: string, ext?: string): string;
|
|
41
|
-
export function isAbsolute(p: string): boolean;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
declare module 'node:child_process' {
|
|
45
|
-
export interface SpawnSyncResult {
|
|
46
|
-
status: number | null;
|
|
47
|
-
stdout: string;
|
|
48
|
-
stderr: string;
|
|
49
|
-
}
|
|
50
|
-
export function spawnSync(
|
|
51
|
-
command: string,
|
|
52
|
-
args: string[],
|
|
53
|
-
options?: { cwd?: string; encoding?: string; env?: Record<string, string | undefined> },
|
|
54
|
-
): SpawnSyncResult;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
declare module 'node:os' {
|
|
58
|
-
export function tmpdir(): string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Minimal surface of Bun's test runner used by this repo's tests. */
|
|
62
|
-
declare module 'bun:test' {
|
|
63
|
-
export function describe(name: string, fn: () => void): void;
|
|
64
|
-
export function it(name: string, fn: () => void | Promise<void>): void;
|
|
65
|
-
export const test: typeof it;
|
|
66
|
-
export function expect(actual: unknown): {
|
|
67
|
-
toBe(expected: unknown): void;
|
|
68
|
-
toEqual(expected: unknown): void;
|
|
69
|
-
toContain(expected: unknown): void;
|
|
70
|
-
toMatch(pattern: RegExp): void;
|
|
71
|
-
toHaveLength(expected: number): void;
|
|
72
|
-
toBeGreaterThan(expected: number): void;
|
|
73
|
-
toBeUndefined(): void;
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
declare const process: {
|
|
78
|
-
env: Record<string, string | undefined>;
|
|
79
|
-
cwd(): string;
|
|
80
|
-
chdir(dir: string): void;
|
|
81
|
-
argv: string[];
|
|
82
|
-
platform: string;
|
|
83
|
-
execPath: string;
|
|
84
|
-
exitCode?: number;
|
|
85
|
-
exit(code?: number): never;
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
declare const console: {
|
|
89
|
-
log(...args: unknown[]): void;
|
|
90
|
-
error(...args: unknown[]): void;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
interface ImportMeta {
|
|
94
|
-
/** Absolute directory of the current module (Bun / bundler convention). */
|
|
95
|
-
readonly dir: string;
|
|
96
|
-
readonly url: string;
|
|
97
|
-
}
|
package/src/hermes-identity.ts
DELETED
package/src/host.ts
DELETED
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The host contract (AGENTS.md: one module per host under `src/hosts/`,
|
|
3
|
-
* implementing this interface; no host-specific branches outside its module).
|
|
4
|
-
*
|
|
5
|
-
* Split into `HostReader` and `HostWriter` so `doctor` is read-only *by
|
|
6
|
-
* construction*: it takes `HostReader[]` and must never import a writer. A
|
|
7
|
-
* module is evaluated whole, so each host's writer implements `HostWriter` in
|
|
8
|
-
* a sibling `<host>-writer.ts` module — loading the reader must not load the
|
|
9
|
-
* writer. test/doctor-imports.test.ts pins that split.
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
/** One MCP server as configured somewhere we can read it. */
|
|
13
|
-
export interface McpServerEntry {
|
|
14
|
-
/** Server name as written in the config (`mcpServers` key / `[mcp_servers.<name>]`). */
|
|
15
|
-
name: string;
|
|
16
|
-
transport: 'stdio' | 'http';
|
|
17
|
-
/** Where the entry came from: host user-level config or an installed plugin. */
|
|
18
|
-
origin: 'user' | 'plugin';
|
|
19
|
-
/** `plugin@marketplace` (or bare plugin name) when origin is `plugin`. */
|
|
20
|
-
pluginId?: string;
|
|
21
|
-
/** Absolute path of the file the entry was read from. */
|
|
22
|
-
file: string;
|
|
23
|
-
/** Directory relative paths resolve against (plugin root, or the config's root). */
|
|
24
|
-
baseDir: string;
|
|
25
|
-
/** stdio: the executable token (spec §7.2.1: a single token, bare name or `./`-relative). */
|
|
26
|
-
command?: string;
|
|
27
|
-
/** stdio args. Never printed by doctor — args can carry credentials. */
|
|
28
|
-
args?: string[];
|
|
29
|
-
/** False when the host config explicitly disables the entry. */
|
|
30
|
-
enabled?: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** One installed plugin in a host's native store. */
|
|
34
|
-
export interface InstalledPlugin {
|
|
35
|
-
/** Host-native id: `plugin@marketplace` where the host tracks marketplaces, else the plugin name. */
|
|
36
|
-
id: string;
|
|
37
|
-
name: string;
|
|
38
|
-
marketplace?: string;
|
|
39
|
-
/** Install directory, when it exists on disk. */
|
|
40
|
-
path?: string;
|
|
41
|
-
/** Every host-native root whose bytes participate in this active install. */
|
|
42
|
-
contentRoots?: Record<string, string>;
|
|
43
|
-
version?: string;
|
|
44
|
-
/** Source sha the host itself recorded (claude-code `gitCommitSha`), if any. */
|
|
45
|
-
sha?: string;
|
|
46
|
-
enabled?: boolean;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export interface HostReader {
|
|
50
|
-
readonly id: string;
|
|
51
|
-
/**
|
|
52
|
-
* True when the host is launched by a macOS GUI app and therefore has no
|
|
53
|
-
* shell PATH — bare commands are suspect there (spec §7.2.1 makes PATH
|
|
54
|
-
* participation client-defined; plugins MUST NOT depend on it).
|
|
55
|
-
*/
|
|
56
|
-
readonly gui: boolean;
|
|
57
|
-
/** Is this host present on this machine? */
|
|
58
|
-
detect(): boolean;
|
|
59
|
-
/** Store directories this host owns (for `targets`/reporting). */
|
|
60
|
-
stores(): string[];
|
|
61
|
-
/** Plugins installed in the host's native store. */
|
|
62
|
-
listInstalled(): InstalledPlugin[];
|
|
63
|
-
/** Every MCP server entry the host will consider: user-level config + each installed plugin. */
|
|
64
|
-
mcpEntries(): McpServerEntry[];
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export interface AddOptions {
|
|
68
|
-
target?: string;
|
|
69
|
-
dryRun?: boolean;
|
|
70
|
-
/** Explicitly replace one selected, proven legacy native install. */
|
|
71
|
-
adoptExisting?: boolean;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export type InstallStatus = 'installed' | 'unchanged' | 'unsupported' | 'unverified' | 'failed';
|
|
75
|
-
|
|
76
|
-
/** Generic mutation result; readers keep their host-native data shapes. */
|
|
77
|
-
export interface InstallOutcome {
|
|
78
|
-
plugin: string;
|
|
79
|
-
target: string;
|
|
80
|
-
status: InstallStatus;
|
|
81
|
-
dryRun: boolean;
|
|
82
|
-
diagnostic?: string;
|
|
83
|
-
nativeId?: string;
|
|
84
|
-
action?: 'install' | 'update' | 'remove';
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
export interface PinOptions {
|
|
88
|
-
/** Report what would change without writing. */
|
|
89
|
-
dryRun?: boolean;
|
|
90
|
-
/** Restrict the pass to these server names (`update` re-applies recorded pins). */
|
|
91
|
-
only?: readonly string[];
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** One bare `command` rewritten to the absolute path it resolved to. */
|
|
95
|
-
export interface PinChange {
|
|
96
|
-
server: string;
|
|
97
|
-
from: string;
|
|
98
|
-
to: string;
|
|
99
|
-
/** The file the rewrite was written to. */
|
|
100
|
-
file: string;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** One bare `command` that did not resolve — left untouched, reported as ✗. */
|
|
104
|
-
export interface PinRefusal {
|
|
105
|
-
server: string;
|
|
106
|
-
command: string;
|
|
107
|
-
file: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export interface PinOutcome {
|
|
111
|
-
changes: PinChange[];
|
|
112
|
-
refusals: PinRefusal[];
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
import type { PluginSource, ResolvedSource } from './source';
|
|
116
|
-
|
|
117
|
-
export interface HostWriter extends HostReader {
|
|
118
|
-
/** Whether this writer implements explicit legacy adoption. */
|
|
119
|
-
readonly supportsAdoption?: boolean;
|
|
120
|
-
add(plugin: PluginSource, resolved: ResolvedSource, opts?: AddOptions): Promise<void | 'unchanged'>;
|
|
121
|
-
remove(id: string): Promise<void>;
|
|
122
|
-
/**
|
|
123
|
-
* Rewrite every *bare* stdio `command` in this plugin's installed copy to the
|
|
124
|
-
* absolute path it resolves to on this process's PATH, so a GUI host with no
|
|
125
|
-
* shell PATH can still launch it (spec §7.2.1: whether a configured PATH
|
|
126
|
-
* participates in bare-name resolution is client-defined; plugins MUST NOT
|
|
127
|
-
* depend on it). A command that does not resolve is left alone and returned
|
|
128
|
-
* in `refusals` — `pin` never guesses.
|
|
129
|
-
*/
|
|
130
|
-
pin(plugin: InstalledPlugin, opts?: PinOptions): Promise<PinOutcome>;
|
|
131
|
-
}
|