plugnz 0.0.0 → 0.0.4

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.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +23 -1
  3. package/bin/plgnz.mjs +3 -0
  4. package/bin/plugnz.mjs +5 -0
  5. package/package.json +25 -6
  6. package/src/cli.ts +489 -0
  7. package/src/compatibility.ts +23 -0
  8. package/src/consumer-profiles.ts +77 -0
  9. package/src/conversion.ts +544 -0
  10. package/src/doctor.ts +277 -0
  11. package/src/exec.ts +91 -0
  12. package/src/fingerprint.ts +77 -0
  13. package/src/globals.d.ts +97 -0
  14. package/src/hermes-identity.ts +4 -0
  15. package/src/host.ts +131 -0
  16. package/src/hosts/claude-code-writer.ts +589 -0
  17. package/src/hosts/claude-code.ts +124 -0
  18. package/src/hosts/codex-writer.ts +418 -0
  19. package/src/hosts/codex.ts +183 -0
  20. package/src/hosts/cursor-writer.ts +279 -0
  21. package/src/hosts/cursor.ts +107 -0
  22. package/src/hosts/dcode-writer.ts +150 -0
  23. package/src/hosts/dcode.ts +74 -0
  24. package/src/hosts/grok-writer.ts +254 -0
  25. package/src/hosts/grok.ts +94 -0
  26. package/src/hosts/hermes-writer.ts +223 -0
  27. package/src/hosts/hermes.ts +88 -0
  28. package/src/hosts/index.ts +25 -0
  29. package/src/hosts/kimi-writer.ts +238 -0
  30. package/src/hosts/kimi.ts +140 -0
  31. package/src/hosts/omp-writer.ts +179 -0
  32. package/src/hosts/omp.ts +153 -0
  33. package/src/hosts/opencode-writer.ts +112 -0
  34. package/src/hosts/opencode.ts +38 -0
  35. package/src/hosts/pi-writer.ts +111 -0
  36. package/src/hosts/pi.ts +48 -0
  37. package/src/hosts/writers.ts +23 -0
  38. package/src/hosts/zcode-cli-writer.ts +239 -0
  39. package/src/hosts/zcode-cli.ts +195 -0
  40. package/src/mcp-write.ts +81 -0
  41. package/src/mcp.ts +161 -0
  42. package/src/paths.ts +101 -0
  43. package/src/pin.ts +135 -0
  44. package/src/source.ts +224 -0
  45. package/src/state-write.ts +25 -0
  46. package/src/state.ts +118 -0
  47. package/src/update.ts +230 -0
  48. package/index.js +0 -1
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The write side of the shared MCP reader (src/mcp.ts).
3
+ *
4
+ * `pinPluginMcpFiles` lives here, not in mcp.ts, because every host reader
5
+ * imports mcp.ts's read helpers — and a module is evaluated whole, so the
6
+ * `writeFileSync` would load with them and reach doctor through the registry.
7
+ * Only the host writers (src/hosts/<host>-writer.ts) import this module;
8
+ * test/doctor-imports.test.ts keeps it out of doctor's import graph.
9
+ */
10
+ import { writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ import type { PinChange, PinOptions, PinOutcome, PinRefusal } from './host';
13
+ import { normalizeTransport, readJson, type PluginMcpCandidate, type RawServerDef } from './mcp';
14
+ import { which } from './exec';
15
+
16
+ /** The file a candidate names, relative to the plugin root. */
17
+ function candidateFile(pluginDir: string, candidate: PluginMcpCandidate): string {
18
+ return candidate.kind === 'spec' ? join(pluginDir, candidate.file) : join(pluginDir, candidate.manifest);
19
+ }
20
+
21
+ /**
22
+ * Rewrite bare stdio `command`s in one plugin copy to the absolute path they
23
+ * resolve to on this process's PATH — the write side of `pin`, shared by every
24
+ * host so the files it rewrites are exactly the `candidates` it reads.
25
+ *
26
+ * Per spec §7.2.1 a `command` is a single executable token, either bare or a
27
+ * plugin-relative path starting with `./`; only the bare form is rewritten
28
+ * here (a `./`-relative or absolute command is already unambiguous). A bare
29
+ * command that does not resolve is never invented: it is returned as a refusal
30
+ * and the file is left as-is.
31
+ *
32
+ * A codex-style pointer manifest (`"mcpServers": "./.mcp.json"`) is not an
33
+ * object, so it is skipped here — the file it points at is a spec candidate of
34
+ * its own and gets pinned directly.
35
+ */
36
+ export function pinPluginMcpFiles(
37
+ pluginDir: string,
38
+ candidates: readonly PluginMcpCandidate[],
39
+ opts: PinOptions = {},
40
+ ): PinOutcome {
41
+ const changes: PinChange[] = [];
42
+ const refusals: PinRefusal[] = [];
43
+ // One server is usually restated across 2–3 files; report each name once
44
+ // (the twins are still rewritten below) so findings and recorded pins don't
45
+ // repeat per file.
46
+ const reported = new Set<string>();
47
+ for (const candidate of candidates) {
48
+ const file = candidateFile(pluginDir, candidate);
49
+ const root = readJson(file);
50
+ if (root === null) continue;
51
+ const servers = root['mcpServers'];
52
+ if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) continue;
53
+ let rewritten = false;
54
+ for (const [name, value] of Object.entries(servers as Record<string, unknown>)) {
55
+ if (opts.only !== undefined && !opts.only.includes(name)) continue;
56
+ if (typeof value !== 'object' || value === null) continue;
57
+ const def = value as RawServerDef;
58
+ if (normalizeTransport(def) !== 'stdio') continue;
59
+ const command = def['command'];
60
+ if (typeof command !== 'string' || command.length === 0 || command.includes('/')) continue;
61
+ const resolved = which(command);
62
+ if (resolved === null) {
63
+ if (!reported.has(name)) {
64
+ reported.add(name);
65
+ refusals.push({ server: name, command, file });
66
+ }
67
+ continue;
68
+ }
69
+ if (!reported.has(name)) {
70
+ reported.add(name);
71
+ changes.push({ server: name, from: command, to: resolved, file });
72
+ }
73
+ if (command !== resolved) {
74
+ def['command'] = resolved;
75
+ rewritten = true;
76
+ }
77
+ }
78
+ if (rewritten && opts.dryRun !== true) writeFileSync(file, JSON.stringify(root, null, 2));
79
+ }
80
+ return { changes, refusals };
81
+ }
package/src/mcp.ts ADDED
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Shared reader for MCP server declarations — read-only.
3
+ *
4
+ * Two shapes occur on disk:
5
+ * - the spec's `mcp.json` / the plugins CLI's `.mcp.json` at the plugin root:
6
+ * `{ $schema, mcpServers: { <name>: { type, command, args, … } } }`
7
+ * (spec §7.2.1: `mcpServers` member values are server configs; stdio
8
+ * servers carry a single-token `command`)
9
+ * - host-native manifests with an `mcpServers` member, measured in two
10
+ * variants: kimi `.kimi-plugin/plugin.json` carries the servers inline
11
+ * (spec-style entries); codex `.codex-plugin/plugin.json` carries a
12
+ * pointer string — `"mcpServers": "./.mcp.json"` — resolved against the
13
+ * plugin root (13 manifests measured: 6 pointers, 7 absent, 0 inline)
14
+ *
15
+ * Measured on this machine, `npx plugins add` dereferences one source into
16
+ * *all* of these per install dir, so the same server usually appears 2–3×;
17
+ * identical restatements are deduped, keeping the highest-priority occurrence.
18
+ *
19
+ * The write side (`pinPluginMcpFiles`, the per-host `pin` implementations'
20
+ * shared rewriter) lives in src/mcp-write.ts so this module stays safe for
21
+ * doctor to load transitively through the host readers.
22
+ */
23
+ import { existsSync, readFileSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ import type { McpServerEntry } from './host';
26
+
27
+ export type RawServerDef = Record<string, unknown>;
28
+
29
+ /**
30
+ * One place a plugin may declare MCP servers, in the host's own priority
31
+ * order: a spec `mcp.json`/`.mcp.json` at the plugin root, or a host-native
32
+ * manifest whose `mcpServers` member is either inline servers or (codex-style)
33
+ * a pointer string at a spec file.
34
+ */
35
+ export type PluginMcpCandidate = { kind: 'spec'; file: string } | { kind: 'inline'; manifest: string };
36
+
37
+ export function readJson(file: string): Record<string, unknown> | null {
38
+ try {
39
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));
40
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** `mcpServers` object from a spec `mcp.json`/`.mcp.json` file, or null. */
47
+ export function specMcpServers(file: string): Record<string, RawServerDef> | null {
48
+ const root = readJson(file);
49
+ if (root === null) return null;
50
+ const servers = root['mcpServers'];
51
+ if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) return null;
52
+ return servers as Record<string, RawServerDef>;
53
+ }
54
+
55
+ /** Inline `mcpServers` object from a host-native plugin manifest, or null. */
56
+ export function inlineMcpServers(manifestFile: string): Record<string, RawServerDef> | null {
57
+ const root = readJson(manifestFile);
58
+ if (root === null) return null;
59
+ const servers = root['mcpServers'];
60
+ if (typeof servers !== 'object' || servers === null || Array.isArray(servers)) return null;
61
+ return servers as Record<string, RawServerDef>;
62
+ }
63
+
64
+ /**
65
+ * The file a host-native manifest's `mcpServers` points at, when it is a
66
+ * codex-style pointer string (`"./.mcp.json"`, resolved against the plugin
67
+ * root), or null when it is not a pointer / points nowhere.
68
+ */
69
+ export function pointerMcpFile(manifestFile: string, pluginDir: string): string | null {
70
+ const root = readJson(manifestFile);
71
+ if (root === null) return null;
72
+ const pointer = root['mcpServers'];
73
+ if (typeof pointer !== 'string') return null;
74
+ const pointed = join(pluginDir, pointer);
75
+ return existsSync(pointed) ? pointed : null;
76
+ }
77
+
78
+ export function normalizeTransport(def: RawServerDef): 'stdio' | 'http' | null {
79
+ const type = typeof def['type'] === 'string' ? def['type'] : undefined;
80
+ if (type === 'http' || type === 'sse' || typeof def['url'] === 'string') return 'http';
81
+ if (type === 'stdio' || typeof def['command'] === 'string') return 'stdio';
82
+ return null;
83
+ }
84
+
85
+ /**
86
+ * Collect deduped MCP entries for one plugin directory.
87
+ * `candidates` is in priority order — the file the host actually launches
88
+ * from comes first (see docs/hosts/<host>.md for per-host evidence).
89
+ */
90
+ export function collectPluginServers(
91
+ pluginId: string,
92
+ pluginDir: string,
93
+ candidates: readonly PluginMcpCandidate[],
94
+ ): McpServerEntry[] {
95
+ const entries: McpServerEntry[] = [];
96
+ const seen = new Set<string>();
97
+ for (const candidate of candidates) {
98
+ // A native manifest resolves to itself (inline servers) or, codex-style,
99
+ // to the spec file its `mcpServers` pointer names.
100
+ let file: string;
101
+ let servers: Record<string, RawServerDef> | null;
102
+ if (candidate.kind === 'spec') {
103
+ file = join(pluginDir, candidate.file);
104
+ servers = specMcpServers(file);
105
+ } else {
106
+ file = join(pluginDir, candidate.manifest);
107
+ const pointed = pointerMcpFile(file, pluginDir);
108
+ if (pointed !== null) {
109
+ file = pointed;
110
+ servers = specMcpServers(pointed);
111
+ } else {
112
+ servers = inlineMcpServers(file);
113
+ }
114
+ }
115
+ if (servers === null) continue;
116
+ for (const [name, def] of Object.entries(servers)) {
117
+ const transport = normalizeTransport(def);
118
+ if (transport === null) continue;
119
+ const command = typeof def['command'] === 'string' ? def['command'] : undefined;
120
+ const rawArgs = def['args'];
121
+ const args = Array.isArray(rawArgs) ? rawArgs.filter((a): a is string => typeof a === 'string') : undefined;
122
+ // Dedupe identical restatements across the candidate files.
123
+ const key = `${name}\0${transport}\0${command ?? ''}\0${JSON.stringify(args ?? [])}`;
124
+ if (seen.has(key)) continue;
125
+ seen.add(key);
126
+ const entry: McpServerEntry = {
127
+ name,
128
+ transport,
129
+ origin: 'plugin',
130
+ pluginId,
131
+ file,
132
+ baseDir: pluginDir,
133
+ };
134
+ if (command !== undefined) entry.command = command;
135
+ if (args !== undefined) entry.args = args;
136
+ if (def['enabled'] === false) entry.enabled = false;
137
+ entries.push(entry);
138
+ }
139
+ }
140
+ return entries;
141
+ }
142
+
143
+ /** Collect entries from a host-level config file (`mcpServers` object shape). */
144
+ export function collectUserServers(file: string, baseDir: string): McpServerEntry[] {
145
+ const servers = specMcpServers(file);
146
+ if (servers === null) return [];
147
+ const entries: McpServerEntry[] = [];
148
+ for (const [name, def] of Object.entries(servers)) {
149
+ const transport = normalizeTransport(def);
150
+ if (transport === null) continue;
151
+ const command = typeof def['command'] === 'string' ? def['command'] : undefined;
152
+ const rawArgs = def['args'];
153
+ const args = Array.isArray(rawArgs) ? rawArgs.filter((a): a is string => typeof a === 'string') : undefined;
154
+ const entry: McpServerEntry = { name, transport, origin: 'user', file, baseDir };
155
+ if (command !== undefined) entry.command = command;
156
+ if (args !== undefined) entry.args = args;
157
+ if (def['enabled'] === false) entry.enabled = false;
158
+ entries.push(entry);
159
+ }
160
+ return entries;
161
+ }
package/src/paths.ts ADDED
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Path resolution for open-plugin.
3
+ *
4
+ * Every filesystem path the tool touches goes through this module so tests can
5
+ * redirect the whole world into a temp directory via `OPEN_PLUGIN_HOME`
6
+ * (AGENTS.md: tests never touch the real home).
7
+ *
8
+ * Resolution order for each host root: a host-specific override env var wins,
9
+ * then `OPEN_PLUGIN_HOME` (a stand-in for `$HOME`), then the real `$HOME`.
10
+ */
11
+ import { dirname, join } from 'node:path';
12
+ import { existsSync } from 'node:fs';
13
+
14
+ /** The home stand-in: `OPEN_PLUGIN_HOME` when set, else the real `$HOME`. */
15
+ export function homeRoot(): string {
16
+ const override = process.env['OPEN_PLUGIN_HOME'];
17
+ if (override && override.length > 0) return override;
18
+ return process.env['HOME'] ?? '.';
19
+ }
20
+
21
+ /** `~/.claude` — claude-code config root (`installed_plugins.json` lives under `plugins/`). */
22
+ export function claudeCodeRoot(): string {
23
+ return process.env['OPEN_PLUGIN_CLAUDE_CODE_ROOT'] ?? join(homeRoot(), '.claude');
24
+ }
25
+
26
+ /** `~/.codex` — codex home: `config.toml` + `plugins/cache/`. */
27
+ export function codexHome(): string {
28
+ return process.env['OPEN_PLUGIN_CODEX_HOME'] ?? join(homeRoot(), '.codex');
29
+ }
30
+
31
+ /** `~/.kimi-code` — kimi root: `config.toml` + `plugins/managed/`. */
32
+ export function kimiRoot(): string {
33
+ return process.env['OPEN_PLUGIN_KIMI_ROOT'] ?? join(homeRoot(), '.kimi-code');
34
+ }
35
+
36
+ /** `~/.cursor` — cursor root: `mcp.json` + `plugins/local/`. */
37
+ export function cursorRoot(): string {
38
+ return process.env['OPEN_PLUGIN_CURSOR_ROOT'] ?? join(homeRoot(), '.cursor');
39
+ }
40
+
41
+ /** `~/.omp` — omp home: its own plugin store under `plugins/` (AGENTS.md hosts list). */
42
+ export function ompRoot(): string {
43
+ return process.env['OPEN_PLUGIN_OMP_ROOT'] ?? join(homeRoot(), '.omp');
44
+ }
45
+
46
+ /** `~/.grok` — Grok Build's native configuration and installed-plugin store. */
47
+ export function grokRoot(): string {
48
+ return process.env['OPEN_PLUGIN_GROK_ROOT'] ?? join(homeRoot(), '.grok');
49
+ }
50
+
51
+ /** Hermes's native plugin home. Portable packages live under `plugins/`. */
52
+ export function hermesRoot(): string {
53
+ return process.env['OPEN_PLUGIN_HERMES_ROOT'] ?? join(homeRoot(), '.hermes');
54
+ }
55
+
56
+ /** Requested config path; writer verifies native HERMES_HOME/config.yaml resolves to it. */
57
+ export function hermesConfigPath(): string {
58
+ return process.env['OPEN_PLUGIN_HERMES_CONFIG_PATH'] ?? join(hermesRoot(), 'config.yaml');
59
+ }
60
+
61
+ /** Official ZCode CLI config and plugin root (`~/.zcode/cli`). */
62
+ export function zcodeCliRoot(): string {
63
+ return join(zcodeStorageRoot(), 'cli');
64
+ }
65
+
66
+ /** Official ZCode keeps config under HOME while allowing its plugin store to move. */
67
+ export function zcodeCliConfigRoot(): string {
68
+ return join(homeRoot(), '.zcode', 'cli');
69
+ }
70
+
71
+ export function zcodeStorageRoot(): string {
72
+ return process.env['ZCODE_STORAGE_DIR'] ?? dirname(zcodeCliConfigRoot());
73
+ }
74
+
75
+ /**
76
+ * open-plugin's own install ledger, written by `add`.
77
+ * When `OPEN_PLUGIN_HOME` is set the ledger is `<home>/state.json`; in real use
78
+ * it lives under `~/.open-plugin/state.json` so we never drop a file in `$HOME`.
79
+ */
80
+ export function stateFile(): string {
81
+ const override = process.env['OPEN_PLUGIN_HOME'];
82
+ if (override && override.length > 0) return join(override, 'state.json');
83
+ return join(process.env['HOME'] ?? '.', '.open-plugin', 'state.json');
84
+ }
85
+
86
+ /**
87
+ * open-plugin's git-source clone cache, written when `add` resolves a git URL
88
+ * (src/source.ts). Same rule as `stateFile()`: `<home>/cache` under
89
+ * `OPEN_PLUGIN_HOME`, `~/.open-plugin/cache` in real use — never a generic
90
+ * `cache/` directory dropped straight into `$HOME`.
91
+ */
92
+ export function cacheRoot(): string {
93
+ const override = process.env['OPEN_PLUGIN_HOME'];
94
+ if (override && override.length > 0) return join(override, 'cache');
95
+ return join(process.env['HOME'] ?? '.', '.open-plugin', 'cache');
96
+ }
97
+
98
+ /** Test/debug helper: does a path exist? */
99
+ export function pathExists(p: string): boolean {
100
+ return existsSync(p);
101
+ }
package/src/pin.ts ADDED
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `plgnz pin` — make plugin-provided MCP servers launchable from a GUI
3
+ * host.
4
+ *
5
+ * A GUI-launched host (cursor) has no shell PATH, so a bare `command` in a
6
+ * plugin's `mcp.json` may resolve to nothing at launch even though it resolves
7
+ * fine in this shell. Spec §7.2.1 leaves PATH participation client-defined and
8
+ * tells plugins not to depend on it; `pin` is the repair: resolve every *bare*
9
+ * stdio command on this process's PATH right now and write the absolute path
10
+ * into the host's copy of the plugin.
11
+ *
12
+ * - Default targets are the GUI hosts (`HostReader.gui`); `--all` covers
13
+ * every host, `--target <host>` picks explicitly.
14
+ * - Only plugin-provided servers are touched. A host's *user-level* MCP
15
+ * config is hand-written and stays hand-written — `pin` never edits it.
16
+ * - A bare command that does not resolve is refused (✗, exit 1) and left
17
+ * bare: a wrong absolute path would be worse than a bare one.
18
+ * - What was pinned is recorded in the install ledger (`state.json`), so
19
+ * `update` can re-apply it after re-adding a copy that restores the
20
+ * source's bare command. Plugins plgnz did not install have no
21
+ * record to write to; that is reported (`!`), not invented.
22
+ *
23
+ * The absolute-path form is not one of the two spec §7.2.1 allows in a
24
+ * `command` (a bare name or a `./`-relative path); it is a host-native repair
25
+ * for a client with no shell PATH, and it keeps the field a single executable
26
+ * token, which is what §7.2.1 is protecting.
27
+ */
28
+ import type { HostWriter } from './host';
29
+ import type { Mark } from './doctor';
30
+ import { writers as allWriters } from './hosts/writers';
31
+ import { findRecord, readState, type InstallRecord } from './state';
32
+ import { writeState } from './state-write';
33
+ import { fingerprintTree } from './fingerprint';
34
+
35
+ export interface PinFinding {
36
+ host: string;
37
+ mark: Mark;
38
+ message: string;
39
+ }
40
+
41
+ export interface PinRunResult {
42
+ findings: PinFinding[];
43
+ exitCode: number;
44
+ }
45
+
46
+ export interface PinRunOptions {
47
+ /** Host ids to pin; empty/absent falls back to the GUI hosts (or all with `all`). */
48
+ targets?: readonly string[];
49
+ /** Every host, not just the GUI ones. */
50
+ all?: boolean;
51
+ /** Report changes without writing them (and without touching the ledger). */
52
+ dryRun?: boolean;
53
+ /** Ledger to read/update; defaults to the real `state.json`. */
54
+ state?: InstallRecord[];
55
+ /** Hosts to consider; defaults to the registry. */
56
+ writers?: readonly HostWriter[];
57
+ }
58
+
59
+ /** The hosts `pin` targets when nothing was asked for: the GUI ones. */
60
+ export function defaultPinTargets(all: readonly HostWriter[] = allWriters): HostWriter[] {
61
+ return all.filter((w) => w.gui);
62
+ }
63
+
64
+ export async function runPin(options: PinRunOptions = {}): Promise<PinRunResult> {
65
+ const candidates = options.writers ?? allWriters;
66
+ const state = options.state ?? readState();
67
+ const selected = selectTargets(candidates, options);
68
+ const findings: PinFinding[] = [];
69
+ let ledgerChanged = false;
70
+
71
+ for (const host of selected) {
72
+ if (!host.detect()) continue;
73
+ for (const plugin of host.listInstalled()) {
74
+ if (plugin.path === undefined) continue;
75
+ const outcome = await host.pin(plugin, { dryRun: options.dryRun });
76
+ const prefix = options.dryRun === true ? '[dry-run] ' : '';
77
+ for (const change of outcome.changes) {
78
+ findings.push({
79
+ host: host.id,
80
+ mark: '✓',
81
+ message: `${prefix}server '${change.server}': pinned '${change.from}' → ${change.to} (plugin ${plugin.id})`,
82
+ });
83
+ }
84
+ for (const refusal of outcome.refusals) {
85
+ findings.push({
86
+ host: host.id,
87
+ mark: '✗',
88
+ message:
89
+ `${prefix}server '${refusal.server}': bare command '${refusal.command}' not found on PATH — not pinned ` +
90
+ `(plugin ${plugin.id})`,
91
+ });
92
+ }
93
+ if (outcome.changes.length === 0) continue;
94
+
95
+ const record = findRecord(state, host.id, plugin.id);
96
+ if (record === undefined) {
97
+ findings.push({
98
+ host: host.id,
99
+ mark: '!',
100
+ message:
101
+ `plugin '${plugin.id}' has no record in state.json — pinned, but plgnz did not install it, ` +
102
+ `so update will not restore the pin`,
103
+ });
104
+ continue;
105
+ }
106
+ if (options.dryRun === true) continue;
107
+ const pins = new Set(record.pins ?? []);
108
+ for (const change of outcome.changes) pins.add(change.server);
109
+ const next = [...pins].sort();
110
+ if (next.length !== (record.pins ?? []).length) {
111
+ record.pins = next;
112
+ ledgerChanged = true;
113
+ }
114
+ try {
115
+ record.installedFingerprint = fingerprintTree(plugin.path);
116
+ ledgerChanged = true;
117
+ } catch (error) {
118
+ findings.push({ host: host.id, mark: '✗', message: `plugin '${plugin.id}' was pinned but its installed bytes could not be recorded — ${(error as Error).message}` });
119
+ }
120
+ }
121
+ }
122
+
123
+ if (ledgerChanged) writeState(state);
124
+ return { findings, exitCode: findings.some((f) => f.mark === '✗') ? 1 : 0 };
125
+ }
126
+
127
+ function selectTargets(all: readonly HostWriter[], options: PinRunOptions): HostWriter[] {
128
+ const targets = options.targets;
129
+ if (targets !== undefined && targets.length > 0) {
130
+ const wanted = new Set(targets);
131
+ return all.filter((w) => wanted.has(w.id));
132
+ }
133
+ if (options.all === true) return [...all];
134
+ return defaultPinTargets(all);
135
+ }