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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 archeism
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,25 @@
1
1
  # plugnz
2
2
 
3
- Package bootstrap. This initial release contains no runtime functionality.
3
+ Open-source installer **and doctor** for agent plugins (Agent Plugins / OpenPlugin spec 1.0.0) and MCP configs — Claude Code, Codex, Cursor, Kimi, and more.
4
+
5
+ ```
6
+ npx plugnz add <plugin-dir-or-marketplace-or-git-url> # native plugin stores
7
+ npx plugnz doctor # dead commands, shadowed entries, stale installs
8
+ npx plugnz pin # absolute command paths for GUI hosts
9
+ npx plugnz update # re-add from the recorded source, re-pin
10
+ ```
11
+
12
+ `pin` rewrites a plugin's bare stdio `command` to the absolute path it resolves
13
+ to right now — a macOS GUI host like Cursor starts with no shell PATH, so a
14
+ spec-valid bare `command` never launches there. `update` is an idempotent
15
+ re-`add` from the source recorded in `state.json`, plus the pins that install
16
+ carried. Both refuse rather than guess, and neither will touch a plugin this
17
+ tool did not install. Details: `docs/pin-and-update.md`.
18
+
19
+ Why another tool: `npx plugins` is closed-source and its Cursor target does not install into Cursor; nothing in the ecosystem checks that an installed plugin still *works*. See `docs/research/`.
20
+
21
+ The `npx` command requires Node.js/npm and Bun on PATH; the CLI runs under Bun. `add` accepts a local plugin or marketplace directory as well as a Git URL. Native plugin targets currently include Claude Code, Codex, Kimi, Cursor, OMP, Hermes, Grok, DCode, and ZCode CLI. Run `plugnz targets --all` to see supported and unsupported capabilities for each host; standalone skill distribution is outside this CLI's scope.
22
+
23
+ Published on npm as `plugnz`; source repository: `archeism/plugnz`.
24
+
25
+ Maintainers: see [publishing instructions](docs/publishing.md) for the manual npm release workflow.
package/bin/plgnz.mjs ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bun
2
+ // Compatibility entrypoint for the previous CLI name.
3
+ import './plugnz.mjs';
package/bin/plugnz.mjs ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // plugnz CLI shim — runs the TypeScript entrypoint directly under Bun.
3
+ import { main } from '../src/cli.ts';
4
+
5
+ process.exitCode = await main(process.argv.slice(2));
package/package.json CHANGED
@@ -1,12 +1,31 @@
1
1
  {
2
2
  "name": "plugnz",
3
- "version": "0.0.0",
4
- "description": "Package bootstrap; implementation forthcoming.",
3
+ "version": "0.0.4",
4
+ "description": "Install, diagnose and update agent plugins (Agent Plugins spec) and MCP configs across Claude Code, Codex, Cursor, Kimi and more",
5
+ "license": "MIT",
5
6
  "type": "module",
6
- "main": "index.js",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/archeism/plugnz.git"
10
+ },
11
+ "bin": {
12
+ "plugnz": "bin/plugnz.mjs",
13
+ "plgnz": "bin/plugnz.mjs"
14
+ },
15
+ "scripts": {
16
+ "test": "bun test",
17
+ "check": "tsc --noEmit"
18
+ },
7
19
  "files": [
8
- "index.js",
9
- "README.md"
20
+ "bin",
21
+ "src",
22
+ "README.md",
23
+ "LICENSE"
10
24
  ],
11
- "license": "UNLICENSED"
25
+ "dependencies": {
26
+ "smol-toml": "^1.8.0"
27
+ },
28
+ "devDependencies": {
29
+ "typescript": "^7.0.2"
30
+ }
12
31
  }
package/src/cli.ts ADDED
@@ -0,0 +1,489 @@
1
+ /**
2
+ * CLI entrypoint. Verbs: add, doctor, pin, update, list, remove, targets
3
+ * (AGENTS.md verbs list).
4
+ */
5
+ import { runDoctor, formatFinding, type DoctorFinding } from './doctor';
6
+ import { hosts } from './hosts';
7
+ import { cleanupWriters, writers } from './hosts/writers';
8
+ import { resolveSource } from './source';
9
+ import { findRecord, readState } from './state';
10
+ import { writeState } from './state-write';
11
+ import type { InstallRecord } from './state';
12
+ import { runPin } from './pin';
13
+ import { runUpdate } from './update';
14
+ import { fingerprintInstallation } from './fingerprint';
15
+ import type { HostReader, InstallOutcome } from './host';
16
+ import { consumerProfiles, findConsumerProfile, type ConsumerProfile } from './consumer-profiles';
17
+ import { CompatibilityError, requireCompatible } from './compatibility';
18
+ import packageJson from '../package.json' with { type: 'json' };
19
+
20
+ const USAGE = `plugnz — install, diagnose and update agent plugins and MCP configs
21
+
22
+ usage: plugnz <verb> [options]
23
+
24
+ verbs:
25
+ add <source> [--target <host>…] [--adopt-existing] install a plugin into each host's native store
26
+ doctor [--json] dead commands, shadowed entries, stale installs (read-only)
27
+ pin [--target <host>] [--all] rewrite bare commands to absolute paths for GUI hosts
28
+ (default targets: the GUI hosts; --all for every host)
29
+ update [name] [--dry-run] idempotent re-add from state.json; re-materializes
30
+ copy-based hosts and re-applies recorded pins
31
+ list list installed plugins per host
32
+ remove <plugin> remove an installed plugin
33
+ targets [--all] list detected agent hosts; --all includes frozen consumer profiles
34
+ `;
35
+
36
+ /** Flags shared by `pin` and `update`. */
37
+ interface VerbFlags {
38
+ positionals: string[];
39
+ targets: string[];
40
+ plugins: string[];
41
+ all: boolean;
42
+ dryRun: boolean;
43
+ adoptExisting: boolean;
44
+ errors: string[];
45
+ }
46
+
47
+ function parseFlags(args: string[]): VerbFlags {
48
+ const flags: VerbFlags = { positionals: [], targets: [], plugins: [], all: false, dryRun: false, adoptExisting: false, errors: [] };
49
+ for (let i = 0; i < args.length; i++) {
50
+ const arg = args[i];
51
+ if (arg === '--target' || arg === '-t') {
52
+ const value = args[i + 1];
53
+ if (value !== undefined && !value.startsWith('-')) {
54
+ flags.targets.push(value);
55
+ i++;
56
+ } else flags.errors.push(`${arg} requires a value`);
57
+ } else if (arg === '--plugin') {
58
+ const value = args[i + 1];
59
+ if (value !== undefined && !value.startsWith('-')) {
60
+ flags.plugins.push(value);
61
+ i++;
62
+ } else flags.errors.push('--plugin requires a value');
63
+ } else if (arg === '--all') {
64
+ flags.all = true;
65
+ } else if (arg === '--dry-run') {
66
+ flags.dryRun = true;
67
+ } else if (arg === '--adopt-existing') {
68
+ flags.adoptExisting = true;
69
+ } else if (arg !== undefined && arg.startsWith('-')) {
70
+ flags.errors.push(`unknown option '${arg}'`);
71
+ } else if (arg !== undefined) {
72
+ flags.positionals.push(arg);
73
+ }
74
+ }
75
+ return flags;
76
+ }
77
+
78
+ function selectPlugins<T extends { name: string }>(plugins: readonly T[], requested: readonly string[]): { selected: T[]; error?: string } {
79
+ const duplicate = requested.find((name, index) => requested.indexOf(name) !== index);
80
+ if (duplicate !== undefined) return { selected: [], error: `duplicate plugin selector '${duplicate}'` };
81
+ const known = new Map(plugins.map((plugin) => [plugin.name, plugin]));
82
+ for (const name of requested) if (!known.has(name)) return { selected: [], error: `unknown plugin '${name}' (available: ${[...known.keys()].join(', ')})` };
83
+ return { selected: requested.length === 0 ? [...plugins] : requested.map((name) => known.get(name)!) };
84
+ }
85
+
86
+ function rejectDisallowed(flags: VerbFlags, allowed: ReadonlySet<'target' | 'plugin' | 'all' | 'dryRun' | 'adoptExisting'>): string | undefined {
87
+ if (flags.errors.length > 0) return flags.errors.join('; ');
88
+ if (flags.targets.length > 0 && !allowed.has('target')) return '--target is not supported by this verb';
89
+ if (flags.plugins.length > 0 && !allowed.has('plugin')) return '--plugin is only supported by add';
90
+ if (flags.all && !allowed.has('all')) return '--all is not supported by this verb';
91
+ if (flags.dryRun && !allowed.has('dryRun')) return '--dry-run is not supported by this verb';
92
+ if (flags.adoptExisting && !allowed.has('adoptExisting')) return '--adopt-existing is only supported by add';
93
+ return undefined;
94
+ }
95
+
96
+ /** Findings print the same way doctor's do: `<host> <mark> <message>`. */
97
+ function printFindings(findings: readonly DoctorFinding[], json: boolean): void {
98
+ if (json) console.log(JSON.stringify(findings, null, 2));
99
+ else for (const f of findings) console.log(formatFinding(f));
100
+ }
101
+
102
+ function printOutcomes(outcomes: readonly InstallOutcome[], json: boolean): void {
103
+ if (json) console.log(JSON.stringify(outcomes, null, 2));
104
+ else for (const outcome of outcomes) console.log(`${outcome.target}\t${outcome.status}\t${outcome.plugin}${outcome.diagnostic ? `\t${outcome.diagnostic}` : ''}`);
105
+ }
106
+
107
+ function select<T extends HostReader>(available: readonly T[], targets: readonly string[]): { selected: T[]; error?: string } {
108
+ const known = new Map(available.map((host) => [host.id, host]));
109
+ const duplicate = targets.find((target, index) => targets.indexOf(target) !== index);
110
+ if (duplicate !== undefined) return { selected: [], error: `duplicate target '${duplicate}'` };
111
+ for (const target of targets) if (!known.has(target)) return { selected: [], error: `unknown target '${target}' (known: ${[...known.keys()].join(', ')})` };
112
+ const selected = targets.length === 0 ? [...available] : targets.map((target) => known.get(target)!);
113
+ const absent = selected.find((host) => !host.detect());
114
+ if (absent !== undefined && targets.length > 0) return { selected: [], error: `requested target '${absent.id}' is not present on this machine` };
115
+ return { selected: selected.filter((host) => host.detect()) };
116
+ }
117
+
118
+ function selectProfiles(targets: readonly string[]): { selected: ConsumerProfile[]; error?: string } {
119
+ const duplicate = targets.find((target, index) => targets.indexOf(target) !== index);
120
+ if (duplicate !== undefined) return { selected: [], error: `duplicate target '${duplicate}'` };
121
+ const selected = targets.length === 0
122
+ ? writers.filter((writer) => writer.detect()).map((writer) => findConsumerProfile(writer.id)!).filter((profile): profile is ConsumerProfile => profile !== undefined)
123
+ : targets.map(findConsumerProfile);
124
+ const unknownIndex = selected.findIndex((profile) => profile === undefined);
125
+ if (unknownIndex !== -1) return { selected: [], error: `unknown target '${targets[unknownIndex]}' (known: ${consumerProfiles.map((profile) => profile.id).join(', ')})` };
126
+ return { selected: selected as ConsumerProfile[] };
127
+ }
128
+
129
+ function compatibilityOutcomes(profiles: readonly ConsumerProfile[], plugins: readonly string[], action: 'install' | 'update', dryRun: boolean): InstallOutcome[] | undefined {
130
+ const refusals = new Map<string, CompatibilityError>();
131
+ for (const profile of profiles) {
132
+ try { requireCompatible(profile, action); }
133
+ catch (error) { if (error instanceof CompatibilityError) refusals.set(profile.id, error); else throw error; }
134
+ }
135
+ if (refusals.size === 0) return undefined;
136
+ const diagnostic = 'not attempted because another selected target could not be admitted';
137
+ return profiles.flatMap((profile) => plugins.map((plugin) => {
138
+ const refusal = refusals.get(profile.id);
139
+ return {
140
+ plugin,
141
+ target: profile.id,
142
+ status: refusal?.status ?? 'failed',
143
+ action,
144
+ dryRun,
145
+ diagnostic: refusal?.message ?? diagnostic,
146
+ } satisfies InstallOutcome;
147
+ }));
148
+ }
149
+
150
+ async function withoutConsole<T>(fn: () => Promise<T>): Promise<T> {
151
+ const original = console.log;
152
+ console.log = () => {};
153
+ try { return await fn(); } finally { console.log = original; }
154
+ }
155
+
156
+ function fail(message: string, code: number): never {
157
+ console.error(message);
158
+ process.exit(code);
159
+ }
160
+
161
+ export async function main(argv: string[]): Promise<number> {
162
+ const args = argv.filter((a) => a !== '--json');
163
+ const json = argv.length !== args.length;
164
+ const verb = args[0];
165
+
166
+ if (verb === '--version' || verb === '-v' || verb === 'version') {
167
+ if (args.length > 1) fail('plugnz version: unexpected argument', 2);
168
+ console.log(json ? JSON.stringify({ name: 'plugnz', version: packageJson.version }) : packageJson.version);
169
+ return 0;
170
+ }
171
+
172
+ if (verb === undefined || verb === 'help' || verb === '--help' || verb === '-h') {
173
+ console.log(USAGE);
174
+ return verb === undefined ? 2 : 0;
175
+ }
176
+ if (verb === 'doctor') {
177
+ const flags = parseFlags(args.slice(1));
178
+ if (rejectDisallowed(flags, new Set(['target'])) || flags.positionals.length > 0) fail(`plugnz doctor: unexpected argument`, 2);
179
+ const selection = select(hosts, flags.targets);
180
+ if (selection.error) {
181
+ if (json) printOutcomes(flags.targets.map((target) => ({ plugin: '*', target, status: 'failed', dryRun: false, diagnostic: selection.error })), true);
182
+ else console.error(`plugnz doctor: ${selection.error}`);
183
+ return 2;
184
+ }
185
+ const { findings, exitCode } = runDoctor(selection.selected);
186
+ if (json) console.log(JSON.stringify(findings, null, 2));
187
+ else for (const f of findings) console.log(formatFinding(f));
188
+ return exitCode;
189
+ }
190
+
191
+ if (verb === 'targets') {
192
+ const flags = parseFlags(args.slice(1));
193
+ const disallowed = rejectDisallowed(flags, new Set(['all']));
194
+ if (disallowed || flags.positionals.length > 0) fail(`plugnz targets: ${disallowed ?? 'unexpected argument'}`, 2);
195
+ const present = hosts.filter(h => h.detect());
196
+ if (json) {
197
+ console.log(JSON.stringify(flags.all ? consumerProfiles : present.map(h => h.id), null, 2));
198
+ } else {
199
+ for (const h of flags.all ? consumerProfiles : present) console.log(h.id);
200
+ }
201
+ return 0;
202
+ }
203
+ if (verb === 'list') {
204
+ const flags = parseFlags(args.slice(1));
205
+ if (rejectDisallowed(flags, new Set(['target'])) || flags.positionals.length > 0) fail(`plugnz list: unexpected argument`, 2);
206
+ const selection = select(hosts, flags.targets);
207
+ if (selection.error) {
208
+ if (json) printOutcomes(flags.targets.map((target) => ({ plugin: '*', target, status: 'failed', dryRun: false, diagnostic: selection.error })), true);
209
+ else console.error(`plugnz list: ${selection.error}`);
210
+ return 2;
211
+ }
212
+ const state = readState();
213
+ const all: any[] = [];
214
+ for (const h of selection.selected) {
215
+ const installed = h.listInstalled();
216
+ const pending = state.filter((record) => record.host === h.id && record.pending !== undefined)
217
+ .map((record) => ({ id: record.id, action: record.pending }));
218
+ if (json) {
219
+ all.push({ host: h.id, plugins: installed, ...(pending.length > 0 ? { pending } : {}) });
220
+ } else {
221
+ for (const p of installed) {
222
+ console.log(`${h.id}\t${p.id}\t${p.version || p.sha || 'unknown'}`);
223
+ }
224
+ for (const record of pending) console.log(`${h.id}\t${record.id}\tpending:${record.action}`);
225
+ }
226
+ }
227
+ if (json) console.log(JSON.stringify(all, null, 2));
228
+ return 0;
229
+ }
230
+ if (verb === 'remove') {
231
+ const flags = parseFlags(args.slice(1));
232
+ const target = flags.positionals[0];
233
+ if (rejectDisallowed(flags, new Set(['target', 'dryRun'])) || !target || flags.positionals.length > 1) fail(`plugnz remove: missing or unexpected plugin id`, 2);
234
+ const selection = select(cleanupWriters, flags.targets);
235
+ if (selection.error) {
236
+ printOutcomes(flags.targets.map((host) => ({ plugin: target, target: host, status: 'failed', dryRun: flags.dryRun, diagnostic: selection.error })), json);
237
+ return 2;
238
+ }
239
+ if (selection.selected.length === 0) {
240
+ printOutcomes([{ plugin: target, target: '*', status: 'failed', action: 'remove', dryRun: flags.dryRun, diagnostic: 'No detected writer targets' }], json);
241
+ return 1;
242
+ }
243
+ let state: InstallRecord[];
244
+ try { state = readState(); }
245
+ catch (error) {
246
+ printOutcomes([{ plugin: target, target: '*', status: 'failed', action: 'remove', dryRun: flags.dryRun, diagnostic: (error as Error).message }], json);
247
+ return 1;
248
+ }
249
+ const outcomes: InstallOutcome[] = [];
250
+ for (const w of selection.selected) {
251
+ const record = findRecord(state, w.id, target);
252
+ const owned = record !== undefined && (record.ownership === 'plgnz' || record.ownership === undefined);
253
+ if (!owned || record?.pending === 'install') {
254
+ outcomes.push({ plugin: target, target: w.id, status: 'failed', action: 'remove', dryRun: flags.dryRun, diagnostic: record?.pending === 'install' ? 'install is pending; refusing removal' : 'no owned install record; refusing removal' });
255
+ continue;
256
+ }
257
+ if (flags.dryRun) {
258
+ outcomes.push({ plugin: target, target: w.id, status: 'installed', action: 'remove', dryRun: true });
259
+ continue;
260
+ }
261
+ try {
262
+ const pending = { ...record, pending: 'remove' as const };
263
+ const pendingState = state.map((candidate) => candidate === record ? pending : candidate);
264
+ writeState(pendingState);
265
+ state = pendingState;
266
+ await w.remove(target);
267
+ const finalized = state.filter((candidate) => candidate !== pending);
268
+ writeState(finalized);
269
+ state = finalized;
270
+ outcomes.push({ plugin: target, target: w.id, status: 'installed', action: 'remove', dryRun: false });
271
+ } catch (error) {
272
+ outcomes.push({ plugin: target, target: w.id, status: 'failed', action: 'remove', dryRun: false, diagnostic: (error as Error).message });
273
+ }
274
+ }
275
+ printOutcomes(outcomes, json);
276
+ return outcomes.some((outcome) => outcome.status === 'failed') ? 1 : 0;
277
+ }
278
+ if (verb === 'add') {
279
+ const flags = parseFlags(args.slice(1));
280
+ const outcomes: InstallOutcome[] = [];
281
+ let activeTarget = '*';
282
+ let activePlugin = '*';
283
+ let expected: Array<{ plugin: string; target: string }> = [];
284
+ const disallowed = rejectDisallowed(flags, new Set(['target', 'plugin', 'dryRun', 'adoptExisting']));
285
+ if (disallowed) {
286
+ if (json) printOutcomes([{ plugin: '*', target: '*', status: 'failed', dryRun: flags.dryRun, diagnostic: disallowed }], true);
287
+ else console.error(`plugnz add: ${disallowed}`);
288
+ return 2;
289
+ }
290
+ if (flags.positionals.length > 1) fail(`plugnz add: unexpected argument: ${flags.positionals[1]}`, 2);
291
+ const sourceArg = flags.positionals[0];
292
+ if (sourceArg === undefined) fail(`plugnz add: missing source`, 2);
293
+
294
+ try {
295
+ const profileSelection = selectProfiles(flags.targets);
296
+ if (profileSelection.error) {
297
+ printOutcomes(flags.targets.map((target) => ({ plugin: '*', target, status: 'failed', dryRun: flags.dryRun, diagnostic: profileSelection.error })), json);
298
+ return 2;
299
+ }
300
+ if (profileSelection.selected.some((profile) => profile.scope === 'excluded-standalone')) {
301
+ const incompatible = compatibilityOutcomes(profileSelection.selected, ['*'], 'install', flags.dryRun)!;
302
+ printOutcomes(incompatible, json);
303
+ return 1;
304
+ }
305
+ if (flags.adoptExisting) {
306
+ const incompatible = compatibilityOutcomes(profileSelection.selected, ['*'], 'install', flags.dryRun);
307
+ if (incompatible !== undefined) {
308
+ printOutcomes(incompatible, json);
309
+ return 1;
310
+ }
311
+ const adoptionSelection = flags.targets.length === 0
312
+ ? select(writers, [])
313
+ : select(writers, profileSelection.selected.map((profile) => profile.id));
314
+ if (adoptionSelection.error) {
315
+ printOutcomes(profileSelection.selected.map((profile) => ({ plugin: '*', target: profile.id, status: 'failed', dryRun: flags.dryRun, diagnostic: adoptionSelection.error })), json);
316
+ return 2;
317
+ }
318
+ const unsupported = adoptionSelection.selected.find((writer) => writer.supportsAdoption !== true);
319
+ if (unsupported !== undefined) {
320
+ const diagnostic = `target '${unsupported.id}' does not support --adopt-existing`;
321
+ printOutcomes(adoptionSelection.selected.map((writer) => ({ plugin: '*', target: writer.id, status: 'unsupported' as const, action: 'install' as const, dryRun: flags.dryRun, diagnostic })), json);
322
+ return 2;
323
+ }
324
+ }
325
+ const resolved = resolveSource(sourceArg);
326
+ const pluginSelection = selectPlugins(resolved.plugins, flags.plugins);
327
+ if (pluginSelection.error) {
328
+ printOutcomes([{ plugin: '*', target: '*', status: 'failed', dryRun: flags.dryRun, diagnostic: pluginSelection.error }], json);
329
+ return 2;
330
+ }
331
+ const incompatible = compatibilityOutcomes(profileSelection.selected, pluginSelection.selected.map((plugin) => plugin.name), 'install', flags.dryRun);
332
+ if (incompatible !== undefined) {
333
+ printOutcomes(incompatible, json);
334
+ return 1;
335
+ }
336
+ const selection = flags.targets.length === 0
337
+ ? select(writers, [])
338
+ : select(writers, profileSelection.selected.map((profile) => profile.id));
339
+ if (selection.error) {
340
+ printOutcomes(profileSelection.selected.map((profile) => ({ plugin: '*', target: profile.id, status: 'failed', dryRun: flags.dryRun, diagnostic: selection.error })), json);
341
+ return 2;
342
+ }
343
+ if (selection.selected.length === 0) throw new Error('No detected writer targets');
344
+ expected = selection.selected.flatMap((writer) => pluginSelection.selected.map((plugin) => ({ plugin: plugin.name, target: writer.id })));
345
+ activeTarget = expected[0]?.target ?? '*';
346
+ activePlugin = expected[0]?.plugin ?? '*';
347
+ let state = readState();
348
+ for (const w of selection.selected) {
349
+ activeTarget = w.id;
350
+ for (const plugin of pluginSelection.selected) {
351
+ activePlugin = plugin.name;
352
+ const nativeId = plugin.marketplace ? `${plugin.name}@${plugin.marketplace}` : plugin.name;
353
+ if (flags.dryRun) {
354
+ if (json) await withoutConsole(() => w.add(plugin, resolved, { dryRun: true, adoptExisting: flags.adoptExisting }));
355
+ else await w.add(plugin, resolved, { dryRun: true, adoptExisting: flags.adoptExisting });
356
+ outcomes.push({ plugin: plugin.name, target: w.id, status: 'installed', action: 'install', dryRun: true, nativeId });
357
+ } else {
358
+ const expectedIds = new Set([nativeId, plugin.name, `${plugin.name}@${plugin.marketplace ?? 'local'}`]);
359
+ const idx = state.findIndex(r => r.host === w.id && expectedIds.has(r.id));
360
+ const previous = idx !== -1 ? state[idx] : undefined;
361
+ const rec: InstallRecord = {
362
+ ...(previous ?? {
363
+ host: w.id,
364
+ id: nativeId,
365
+ }),
366
+ source: resolved.sourceUri,
367
+ sourceSha: resolved.sha,
368
+ ownership: previous?.ownership ?? 'plgnz',
369
+ pending: 'install',
370
+ };
371
+ const pendingState = idx === -1 ? [...state, rec] : state.map((candidate) => candidate === previous ? rec : candidate);
372
+ writeState(pendingState);
373
+ state = pendingState;
374
+ const writerResult = await w.add(plugin, resolved, { dryRun: false, adoptExisting: flags.adoptExisting });
375
+ const installed = w.listInstalled();
376
+ const expectedMarketplace = plugin.marketplace ?? 'local';
377
+ const match = installed.find(p => expectedIds.has(p.id) && p.name === plugin.name &&
378
+ (p.marketplace === expectedMarketplace || (expectedMarketplace === 'local' && p.marketplace === undefined)) && p.enabled !== false);
379
+ if (match?.path === undefined) throw new Error(`native install readback is missing ${nativeId}`);
380
+ const finalized: InstallRecord = {
381
+ ...rec,
382
+ id: match?.id ?? nativeId,
383
+ source: resolved.sourceUri,
384
+ sourceSha: resolved.sha,
385
+ installedAt: new Date().toISOString(),
386
+ sourceDir: plugin.dir,
387
+ installedFingerprint: fingerprintInstallation(match),
388
+ ...(plugin.contentFingerprint !== undefined ? { fingerprint: plugin.contentFingerprint } : {}),
389
+ };
390
+ delete finalized.pending;
391
+ const finalizedState = state.map((candidate) => candidate === rec ? finalized : candidate);
392
+ writeState(finalizedState);
393
+ state = finalizedState;
394
+ outcomes.push({ plugin: plugin.name, target: w.id, status: writerResult === 'unchanged' ? 'unchanged' : 'installed', action: 'install', dryRun: false, nativeId: match.id });
395
+ }
396
+ }
397
+ }
398
+ printOutcomes(outcomes, json);
399
+ } catch (e: any) {
400
+ const reported = new Set(outcomes.map((outcome) => `${outcome.plugin}\u0000${outcome.target}`));
401
+ const failures: InstallOutcome[] = [];
402
+ if (expected.length === 0) {
403
+ failures.push({ plugin: '*', target: e instanceof CompatibilityError ? e.target : activeTarget, status: e instanceof CompatibilityError ? e.status : 'failed', action: 'install', dryRun: flags.dryRun, diagnostic: e.message });
404
+ } else {
405
+ for (const pair of expected) {
406
+ const key = `${pair.plugin}\u0000${pair.target}`;
407
+ if (reported.has(key)) continue;
408
+ failures.push({
409
+ plugin: pair.plugin,
410
+ target: pair.target,
411
+ status: e instanceof CompatibilityError && pair.target === e.target ? e.status : 'failed',
412
+ action: 'install',
413
+ dryRun: flags.dryRun,
414
+ diagnostic: pair.plugin === activePlugin && pair.target === activeTarget ? e.message : 'not attempted after an earlier install failure',
415
+ });
416
+ }
417
+ }
418
+ if (json) printOutcomes([...outcomes, ...failures], true);
419
+ else console.error(e.message);
420
+ return 1;
421
+ }
422
+ return 0;
423
+ }
424
+ if (verb === 'pin') {
425
+ const flags = parseFlags(args.slice(1));
426
+ if (rejectDisallowed(flags, new Set(['target', 'all', 'dryRun'])) || flags.positionals.length > 0) fail(`plugnz pin: unexpected argument: ${flags.positionals[0]}`, 2);
427
+ const known = new Set(writers.map((w) => w.id));
428
+ for (const target of flags.targets) {
429
+ if (!known.has(target)) {
430
+ fail(`plugnz pin: unknown target '${target}' (known: ${[...known].join(', ')})`, 2);
431
+ }
432
+ }
433
+ const candidates = flags.targets.length > 0
434
+ ? writers.filter((writer) => flags.targets.includes(writer.id))
435
+ : flags.all ? [...writers] : writers.filter((writer) => writer.gui);
436
+ const detected = candidates.filter((writer) => writer.detect());
437
+ if (detected.length === 0) {
438
+ console.error('plugnz pin: No detected writer targets');
439
+ return 1;
440
+ }
441
+ const result = await runPin({ targets: flags.targets, all: flags.all, dryRun: flags.dryRun, writers: detected });
442
+ printFindings(result.findings, json);
443
+ return result.exitCode;
444
+ }
445
+ if (verb === 'update') {
446
+ const flags = parseFlags(args.slice(1));
447
+ if (rejectDisallowed(flags, new Set(['target', 'dryRun'])) || flags.positionals.length > 1) fail(`plugnz update: unexpected argument: ${flags.positionals[1]}`, 2);
448
+ const profileSelection = selectProfiles(flags.targets);
449
+ if (profileSelection.error) {
450
+ printOutcomes(flags.targets.map((target) => ({ plugin: flags.positionals[0] ?? '*', target, status: 'failed', dryRun: flags.dryRun, diagnostic: profileSelection.error })), json);
451
+ return 2;
452
+ }
453
+ const incompatible = compatibilityOutcomes(profileSelection.selected, [flags.positionals[0] ?? '*'], 'update', flags.dryRun);
454
+ if (incompatible !== undefined) {
455
+ printOutcomes(incompatible, json);
456
+ return 1;
457
+ }
458
+ const selection = flags.targets.length === 0
459
+ ? select(writers, [])
460
+ : select(writers, profileSelection.selected.map((profile) => profile.id));
461
+ if (selection.error) {
462
+ printOutcomes(profileSelection.selected.map((profile) => ({ plugin: flags.positionals[0] ?? '*', target: profile.id, status: 'failed', dryRun: flags.dryRun, diagnostic: selection.error })), json);
463
+ return 2;
464
+ }
465
+ if (selection.selected.length === 0) {
466
+ printOutcomes([{ plugin: flags.positionals[0] ?? '*', target: '*', status: 'failed', dryRun: flags.dryRun, diagnostic: 'No detected writer targets' }], json);
467
+ return 1;
468
+ }
469
+ try {
470
+ const result = json && flags.dryRun
471
+ ? await withoutConsole(() => runUpdate(flags.positionals[0], { dryRun: true, writers: selection.selected }))
472
+ : await runUpdate(flags.positionals[0], { dryRun: flags.dryRun, writers: selection.selected });
473
+ if (json) printOutcomes(result.findings.map((finding) => ({ plugin: flags.positionals[0] ?? '*', target: finding.host, status: finding.status ?? (finding.mark === '✓' ? 'installed' : finding.mark === '!' ? 'unverified' : 'failed'), action: 'update', dryRun: flags.dryRun, diagnostic: finding.message })), true);
474
+ else printFindings(result.findings, false);
475
+ return result.exitCode;
476
+ } catch (error) {
477
+ printOutcomes([{ plugin: flags.positionals[0] ?? '*', target: '*', status: 'failed', action: 'update', dryRun: flags.dryRun, diagnostic: (error as Error).message }], json);
478
+ return 1;
479
+ }
480
+ }
481
+
482
+ fail(`plugnz: unknown verb '${verb}'\n\n${USAGE}`, 2);
483
+ }
484
+
485
+ if (process.argv[1] !== undefined && process.argv[1].endsWith('cli.ts')) {
486
+ void main(process.argv.slice(2)).then((code) => {
487
+ process.exitCode = code;
488
+ });
489
+ }
@@ -0,0 +1,23 @@
1
+ import type { InstallStatus } from './host';
2
+ import type { ConsumerProfile } from './consumer-profiles';
3
+
4
+ export type CompatibilityStatus = Extract<InstallStatus, 'unsupported' | 'unverified'> | 'supported';
5
+ export type ConsumerCapability = 'install' | 'update' | 'commandProjection' | 'userOnlySkills';
6
+
7
+ /** A target-specific refusal that callers can render as an InstallOutcome. */
8
+ export class CompatibilityError extends Error {
9
+ constructor(
10
+ readonly target: string,
11
+ readonly capability: ConsumerCapability,
12
+ readonly status: Extract<CompatibilityStatus, 'unsupported' | 'unverified'>,
13
+ readonly evidence: string,
14
+ ) {
15
+ super(`target '${target}' is ${status} for ${capability}; evidence: ${evidence}`);
16
+ this.name = 'CompatibilityError';
17
+ }
18
+ }
19
+
20
+ export function requireCompatible(profile: ConsumerProfile, capability: ConsumerCapability): void {
21
+ const status = profile.capabilities[capability];
22
+ if (status !== 'supported') throw new CompatibilityError(profile.id, capability, status, profile.evidence);
23
+ }