opensocrates 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -22,22 +22,46 @@ import { Readable } from "node:stream";
22
22
  import { pipeline } from "node:stream/promises";
23
23
  import { fileURLToPath } from "node:url";
24
24
 
25
- export const PRODUCT_VERSION = "1.0.0";
25
+ export const PRODUCT_VERSION = "1.1.0";
26
26
  export const REPOSITORY = "ParkerHwang/OpenSocrates";
27
27
  export const MARKETPLACE_NAME = "opensocrates";
28
28
  export const PLUGIN_NAME = "opensocrates";
29
29
  export const PLUGIN_ID = `${PLUGIN_NAME}@${MARKETPLACE_NAME}`;
30
- export const ASSET_NAME = `opensocrates-${PRODUCT_VERSION}-codex-plugin.zip`;
30
+ export const DEFAULT_HOST = "codex";
31
+ export const SUPPORTED_HOSTS = Object.freeze(["claude", "codex"]);
32
+ export function assetNameFor(host = DEFAULT_HOST) {
33
+ if (!SUPPORTED_HOSTS.includes(host)) {
34
+ fail(`unsupported host ${JSON.stringify(host)}`);
35
+ }
36
+ return `opensocrates-${PRODUCT_VERSION}-${host}-plugin.zip`;
37
+ }
38
+ export const ASSET_NAME = assetNameFor(DEFAULT_HOST);
31
39
  export const CHECKSUM_NAME = `${ASSET_NAME}.sha256`;
32
40
 
33
41
  const MARKER_NAME = ".opensocrates-managed.json";
34
- const MARKER = Object.freeze({
42
+ const CODEX_MARKER = Object.freeze({
43
+ schemaVersion: 1,
44
+ marketplaceName: MARKETPLACE_NAME,
45
+ pluginName: PLUGIN_NAME,
46
+ });
47
+ const CLAUDE_MARKER = Object.freeze({
35
48
  schemaVersion: 1,
36
49
  marketplaceName: MARKETPLACE_NAME,
37
50
  pluginName: PLUGIN_NAME,
51
+ host: "claude",
52
+ });
53
+ const HOST_LAYOUTS = Object.freeze({
54
+ claude: {
55
+ marketplaceRelative: join(".claude-plugin", "marketplace.json"),
56
+ pluginRelative: join("plugins", PLUGIN_NAME),
57
+ manifestRelative: join(".claude-plugin", "plugin.json"),
58
+ },
59
+ codex: {
60
+ marketplaceRelative: join(".agents", "plugins", "marketplace.json"),
61
+ pluginRelative: join("build", "generated", "plugins", "codex"),
62
+ manifestRelative: join(".codex-plugin", "plugin.json"),
63
+ },
38
64
  });
39
- const MARKETPLACE_RELATIVE = join(".agents", "plugins", "marketplace.json");
40
- const PLUGIN_RELATIVE = join("build", "generated", "plugins", "codex");
41
65
  const MAX_DOWNLOAD_BYTES = 2 * 1024 * 1024 * 1024;
42
66
  const MAX_ARCHIVE_ENTRIES = 10_000;
43
67
 
@@ -65,12 +89,16 @@ function jsonEqual(left, right) {
65
89
  );
66
90
  }
67
91
 
68
- export function markerMatches(value) {
92
+ function markerFor(host) {
93
+ return host === "claude" ? CLAUDE_MARKER : CODEX_MARKER;
94
+ }
95
+
96
+ export function markerMatches(value, host = DEFAULT_HOST) {
69
97
  return (
70
98
  value !== null &&
71
99
  typeof value === "object" &&
72
100
  !Array.isArray(value) &&
73
- jsonEqual(value, MARKER)
101
+ jsonEqual(value, markerFor(host))
74
102
  );
75
103
  }
76
104
 
@@ -120,13 +148,21 @@ export function parseCli(argv) {
120
148
  if (!actions.has(action)) {
121
149
  fail(`unknown action ${JSON.stringify(action)}`);
122
150
  }
123
- const options = { action, asset: null, checksum: null };
151
+ const options = { action, host: DEFAULT_HOST, asset: null, checksum: null };
124
152
  while (args.length > 0) {
125
153
  const flag = args.shift();
126
154
  if (flag === "--help" || flag === "-h") {
127
155
  options.action = "help";
128
156
  continue;
129
157
  }
158
+ if (flag === "--host") {
159
+ const host = args.shift();
160
+ if (!SUPPORTED_HOSTS.includes(host)) {
161
+ fail(`--host must be one of: ${SUPPORTED_HOSTS.join(", ")}`);
162
+ }
163
+ options.host = host;
164
+ continue;
165
+ }
130
166
  if (flag !== "--asset" && flag !== "--checksum") {
131
167
  fail(`unknown option ${JSON.stringify(flag)}`);
132
168
  }
@@ -149,28 +185,40 @@ function showHelp() {
149
185
  console.log(`OpenSocrates ${PRODUCT_VERSION}
150
186
 
151
187
  Usage:
152
- opensocrates install [--asset ZIP --checksum SHA256]
153
- opensocrates status
154
- opensocrates update [--asset ZIP --checksum SHA256]
155
- opensocrates remove
156
- opensocrates verify [--asset ZIP --checksum SHA256]
188
+ opensocrates install [--host codex|claude] [--asset ZIP --checksum SHA256]
189
+ opensocrates status [--host codex|claude]
190
+ opensocrates update [--host codex|claude] [--asset ZIP --checksum SHA256]
191
+ opensocrates remove [--host codex|claude]
192
+ opensocrates verify [--host codex|claude] [--asset ZIP --checksum SHA256]
157
193
 
158
194
  Without --asset, install, update, and verify download the v${PRODUCT_VERSION}
159
- package and checksum from GitHub Releases.
195
+ package and checksum from GitHub Releases. The default host is codex.
160
196
  `);
161
197
  }
162
198
 
163
- function managedPaths() {
164
- const configured = process.env.CODEX_HOME;
165
- const codexHome = resolve(configured ? configured : join(homedir(), ".codex"));
166
- const root = join(codexHome, "managed-marketplaces", MARKETPLACE_NAME);
199
+ function managedPaths(host) {
200
+ const configured =
201
+ host === "claude" ? process.env.CLAUDE_CONFIG_DIR : process.env.CODEX_HOME;
202
+ const configuredHome = resolve(
203
+ configured ? configured : join(homedir(), host === "claude" ? ".claude" : ".codex"),
204
+ );
205
+ let hostHome = configuredHome;
206
+ try {
207
+ hostHome = realpathSync(configuredHome);
208
+ } catch {
209
+ // The installer creates a missing host home below after validating its
210
+ // explicit, non-recursive target. Lexical resolution is the only
211
+ // available normalization until then.
212
+ }
213
+ const root = join(hostHome, "managed-marketplaces", MARKETPLACE_NAME);
214
+ const layout = HOST_LAYOUTS[host];
167
215
  return {
168
- codexHome,
216
+ hostHome,
169
217
  root,
170
218
  parent: dirname(root),
171
219
  marker: join(root, MARKER_NAME),
172
- marketplace: join(root, MARKETPLACE_RELATIVE),
173
- plugin: join(root, PLUGIN_RELATIVE),
220
+ marketplace: join(root, layout.marketplaceRelative),
221
+ plugin: join(root, layout.pluginRelative),
174
222
  };
175
223
  }
176
224
 
@@ -178,6 +226,10 @@ function codexBinary() {
178
226
  return process.env.CODEX_BIN || "codex";
179
227
  }
180
228
 
229
+ function claudeBinary() {
230
+ return process.env.CLAUDE_BIN || "claude";
231
+ }
232
+
181
233
  function run(command, args, { allowFailure = false } = {}) {
182
234
  const result = spawnSync(command, args, {
183
235
  encoding: "utf8",
@@ -197,47 +249,97 @@ function run(command, args, { allowFailure = false } = {}) {
197
249
  return result;
198
250
  }
199
251
 
200
- function runCodexJson(args) {
201
- const result = run(codexBinary(), args);
252
+ function runJson(command, args, label) {
253
+ const result = run(command, args);
202
254
  try {
203
255
  const payload = JSON.parse(result.stdout);
204
- if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
205
- fail("Codex returned a non-object JSON value");
256
+ if (payload === null || typeof payload !== "object") {
257
+ fail(`${label} returned a non-container JSON value`);
206
258
  }
207
259
  return payload;
208
260
  } catch (error) {
209
261
  if (error instanceof InstallerError) {
210
262
  throw error;
211
263
  }
212
- fail(`Codex returned invalid JSON for ${args.join(" ")}: ${error.message}`);
264
+ fail(`${label} returned invalid JSON for ${args.join(" ")}: ${error.message}`);
265
+ }
266
+ }
267
+
268
+ function runCodexJson(args) {
269
+ const payload = runJson(codexBinary(), args, "Codex");
270
+ if (Array.isArray(payload)) {
271
+ fail("Codex returned an unexpected JSON array");
213
272
  }
273
+ return payload;
214
274
  }
215
275
 
216
- function requireCodex() {
276
+ function runClaudeJson(args) {
277
+ return runJson(claudeBinary(), args, "Claude Code");
278
+ }
279
+
280
+ function versionAtLeast(value, minimum) {
281
+ const match = String(value).match(/(\d+)\.(\d+)\.(\d+)/u);
282
+ if (!match) {
283
+ return false;
284
+ }
285
+ const current = match.slice(1).map(Number);
286
+ for (let index = 0; index < minimum.length; index += 1) {
287
+ if (current[index] > minimum[index]) return true;
288
+ if (current[index] < minimum[index]) return false;
289
+ }
290
+ return true;
291
+ }
292
+
293
+ function requireHost(host) {
294
+ if (host === "claude") {
295
+ const result = run(claudeBinary(), ["--version"]);
296
+ if (!versionAtLeast(result.stdout, [2, 1, 205])) {
297
+ fail(
298
+ `Claude Code 2.1.205 or later is required for structured selector output; got ${result.stdout.trim()}`,
299
+ );
300
+ }
301
+ return;
302
+ }
217
303
  run(codexBinary(), ["--version"]);
218
304
  }
219
305
 
220
- function marketplaceEntry() {
221
- const payload = runCodexJson(["plugin", "marketplace", "list", "--json"]);
222
- const entries = payload.marketplaces;
306
+ function marketplaceEntries(host) {
307
+ const payload =
308
+ host === "claude"
309
+ ? runClaudeJson(["plugin", "marketplace", "list", "--json"])
310
+ : runCodexJson(["plugin", "marketplace", "list", "--json"]).marketplaces;
311
+ const entries = payload;
223
312
  if (!Array.isArray(entries)) {
224
- fail("Codex marketplace list returned an unexpected schema");
313
+ fail(`${host} marketplace list returned an unexpected schema`);
225
314
  }
315
+ return entries;
316
+ }
317
+
318
+ function marketplaceEntry(host) {
319
+ const entries = marketplaceEntries(host);
226
320
  const matches = entries.filter((entry) => entry?.name === MARKETPLACE_NAME);
227
321
  if (matches.length > 1) {
228
- fail(`Codex reported duplicate marketplaces named ${MARKETPLACE_NAME}`);
322
+ fail(`${host} reported duplicate marketplaces named ${MARKETPLACE_NAME}`);
229
323
  }
230
324
  return matches[0] ?? null;
231
325
  }
232
326
 
233
- function pluginState() {
327
+ function pluginState(host) {
328
+ if (host === "claude") {
329
+ const installed = runClaudeJson(["plugin", "list", "--json"]);
330
+ if (!Array.isArray(installed)) {
331
+ fail("Claude Code plugin list returned an unexpected schema");
332
+ }
333
+ const matches = installed.filter((entry) => entry?.id === PLUGIN_ID);
334
+ if (matches.length > 1) {
335
+ fail(`Claude Code reported duplicate entries for ${PLUGIN_ID}`);
336
+ }
337
+ return matches.length === 1
338
+ ? { kind: "installed", version: matches[0].version ?? null }
339
+ : { kind: "missing", version: null };
340
+ }
234
341
  const payload = runCodexJson([
235
- "plugin",
236
- "list",
237
- "--marketplace",
238
- MARKETPLACE_NAME,
239
- "--available",
240
- "--json",
342
+ "plugin", "list", "--marketplace", MARKETPLACE_NAME, "--available", "--json",
241
343
  ]);
242
344
  const installed = Array.isArray(payload.installed) ? payload.installed : null;
243
345
  const available = Array.isArray(payload.available) ? payload.available : null;
@@ -258,6 +360,56 @@ function pluginState() {
258
360
  return { kind: "missing", version: null };
259
361
  }
260
362
 
363
+ function detectLegacyClaudeInstallation() {
364
+ const marketplaces = marketplaceEntries("claude").filter(
365
+ (entry) =>
366
+ typeof entry?.name === "string" &&
367
+ entry.name !== MARKETPLACE_NAME &&
368
+ entry.name.toLowerCase() === MARKETPLACE_NAME,
369
+ );
370
+ const plugins = runClaudeJson(["plugin", "list", "--json"]);
371
+ const legacyPlugins = Array.isArray(plugins)
372
+ ? plugins.filter(
373
+ (entry) =>
374
+ typeof entry?.id === "string" &&
375
+ entry.id !== PLUGIN_ID &&
376
+ entry.id.toLowerCase() === PLUGIN_ID,
377
+ )
378
+ : [];
379
+ const names = marketplaces.map((entry) => entry.name).join(", ") || "OpenSocrates";
380
+ return { found: marketplaces.length > 0 || legacyPlugins.length > 0, names };
381
+ }
382
+
383
+ // Install and update write into the managed root, so a case-variant pre-1.0
384
+ // registration must be resolved by the user first. Status and remove are
385
+ // diagnostic and scoped to the root this installer owns, so they only warn:
386
+ // blocking them would strand a user who needs to inspect or back out.
387
+ function requireNoLegacyClaudeInstallation() {
388
+ const legacy = detectLegacyClaudeInstallation();
389
+ if (legacy.found) {
390
+ fail(
391
+ `a legacy Claude marketplace (${legacy.names}) is installed; OpenSocrates will not remove it automatically. ` +
392
+ "Uninstall its plugin and marketplace explicitly, then rerun this command. See the README migration section.",
393
+ );
394
+ }
395
+ }
396
+
397
+ function warnLegacyClaudeInstallation() {
398
+ let legacy;
399
+ try {
400
+ legacy = detectLegacyClaudeInstallation();
401
+ } catch {
402
+ return;
403
+ }
404
+ if (legacy.found) {
405
+ console.warn(
406
+ `warning: a legacy Claude marketplace (${legacy.names}) is also registered. ` +
407
+ "OpenSocrates never removes it automatically; this command only affects the root it owns. " +
408
+ "See the README migration section.",
409
+ );
410
+ }
411
+ }
412
+
261
413
  async function readJsonObject(target) {
262
414
  let payload;
263
415
  try {
@@ -271,13 +423,13 @@ async function readJsonObject(target) {
271
423
  return payload;
272
424
  }
273
425
 
274
- async function requireOwnedRoot(root) {
426
+ async function requireOwnedRoot(root, host) {
275
427
  const info = await lstat(root);
276
428
  if (!info.isDirectory() || info.isSymbolicLink()) {
277
429
  fail(`managed marketplace path is not an owned directory: ${root}`);
278
430
  }
279
431
  const marker = await readJsonObject(join(root, MARKER_NAME));
280
- if (!markerMatches(marker)) {
432
+ if (!markerMatches(marker, host)) {
281
433
  fail(`managed marketplace path has no valid ownership marker: ${root}`);
282
434
  }
283
435
  }
@@ -321,16 +473,18 @@ async function resolveAssetInputs(options, scratch) {
321
473
  return { asset: options.asset, checksum: options.checksum };
322
474
  }
323
475
  const base = `https://github.com/${REPOSITORY}/releases/download/v${PRODUCT_VERSION}`;
324
- const asset = join(scratch, ASSET_NAME);
325
- const checksum = join(scratch, CHECKSUM_NAME);
476
+ const assetName = assetNameFor(options.host);
477
+ const checksumName = `${assetName}.sha256`;
478
+ const asset = join(scratch, assetName);
479
+ const checksum = join(scratch, checksumName);
326
480
  console.log(`Downloading OpenSocrates ${PRODUCT_VERSION} from GitHub Releases...`);
327
- await downloadFile(`${base}/${ASSET_NAME}`, asset);
328
- await downloadFile(`${base}/${CHECKSUM_NAME}`, checksum);
481
+ await downloadFile(`${base}/${assetName}`, asset);
482
+ await downloadFile(`${base}/${checksumName}`, checksum);
329
483
  return { asset, checksum };
330
484
  }
331
485
 
332
- async function verifyOuterChecksum(asset, checksum) {
333
- const expected = parseChecksumText(await readFile(checksum, "utf8"));
486
+ async function verifyOuterChecksum(asset, checksum, host) {
487
+ const expected = parseChecksumText(await readFile(checksum, "utf8"), assetNameFor(host));
334
488
  const actual = await sha256File(asset);
335
489
  if (actual !== expected) {
336
490
  fail(`release checksum mismatch: expected ${expected}, got ${actual}`);
@@ -420,8 +574,8 @@ async function verifyPackageChecksums(pluginRoot) {
420
574
  return declared.size;
421
575
  }
422
576
 
423
- async function verifyExtractedPackage(pluginRoot) {
424
- const manifest = await readJsonObject(join(pluginRoot, ".codex-plugin", "plugin.json"));
577
+ async function verifyExtractedPackage(pluginRoot, host) {
578
+ const manifest = await readJsonObject(join(pluginRoot, HOST_LAYOUTS[host].manifestRelative));
425
579
  if (manifest.name !== PLUGIN_NAME || manifest.version !== PRODUCT_VERSION) {
426
580
  fail(
427
581
  `plugin manifest mismatch: expected ${PLUGIN_NAME} ${PRODUCT_VERSION}, ` +
@@ -431,7 +585,7 @@ async function verifyExtractedPackage(pluginRoot) {
431
585
  const release = await readJsonObject(join(pluginRoot, "release-manifest.json"));
432
586
  if (
433
587
  release.product_version !== PRODUCT_VERSION ||
434
- release.host !== "codex" ||
588
+ release.host !== host ||
435
589
  release.schema !== "opensocrates.plugin-release-manifest/1.0.0"
436
590
  ) {
437
591
  fail("package release manifest does not match this installer");
@@ -454,10 +608,10 @@ async function prepareVerifiedPackage(options) {
454
608
  const scratch = await mkdtemp(join(tmpdir(), "opensocrates-install-"));
455
609
  try {
456
610
  const { asset, checksum } = await resolveAssetInputs(options, scratch);
457
- await verifyOuterChecksum(asset, checksum);
611
+ await verifyOuterChecksum(asset, checksum, options.host);
458
612
  const pluginRoot = join(scratch, "plugin");
459
613
  await extractArchive(asset, pluginRoot);
460
- const checkedFiles = await verifyExtractedPackage(pluginRoot);
614
+ const checkedFiles = await verifyExtractedPackage(pluginRoot, options.host);
461
615
  return { scratch, pluginRoot, checkedFiles };
462
616
  } catch (error) {
463
617
  await rm(scratch, { recursive: true, force: true });
@@ -465,7 +619,26 @@ async function prepareVerifiedPackage(options) {
465
619
  }
466
620
  }
467
621
 
468
- function expectedMarketplace() {
622
+ function expectedMarketplace(host) {
623
+ if (host === "claude") {
624
+ return {
625
+ name: MARKETPLACE_NAME,
626
+ owner: { name: "Parker Hwang" },
627
+ metadata: {
628
+ description: "OpenSocrates reasoning support for Claude Code and Cowork",
629
+ version: PRODUCT_VERSION,
630
+ },
631
+ plugins: [
632
+ {
633
+ name: PLUGIN_NAME,
634
+ source: `./plugins/${PLUGIN_NAME}`,
635
+ description:
636
+ "Local reasoning-system selection for Claude Code and Cowork, plus reusable Claude skills.",
637
+ category: "workflow",
638
+ },
639
+ ],
640
+ };
641
+ }
469
642
  return {
470
643
  name: MARKETPLACE_NAME,
471
644
  interface: { displayName: "OpenSocrates" },
@@ -486,23 +659,24 @@ function expectedMarketplace() {
486
659
  };
487
660
  }
488
661
 
489
- async function buildStagingTree(parent, pluginSource) {
662
+ async function buildStagingTree(parent, pluginSource, host) {
663
+ const layout = HOST_LAYOUTS[host];
490
664
  const staging = await mkdtemp(join(parent, ".opensocrates.staging-"));
491
665
  try {
492
- const marketplace = join(staging, MARKETPLACE_RELATIVE);
493
- const plugin = join(staging, PLUGIN_RELATIVE);
666
+ const marketplace = join(staging, layout.marketplaceRelative);
667
+ const plugin = join(staging, layout.pluginRelative);
494
668
  await mkdir(dirname(marketplace), { recursive: true, mode: 0o700 });
495
669
  await mkdir(dirname(plugin), { recursive: true, mode: 0o700 });
496
670
  await cp(pluginSource, plugin, { recursive: true, preserveTimestamps: true });
497
- await writeFile(marketplace, `${JSON.stringify(expectedMarketplace(), null, 2)}\n`, {
671
+ await writeFile(marketplace, `${JSON.stringify(expectedMarketplace(host), null, 2)}\n`, {
498
672
  encoding: "utf8",
499
673
  mode: 0o600,
500
674
  });
501
- await writeFile(join(staging, MARKER_NAME), `${JSON.stringify(MARKER, null, 2)}\n`, {
675
+ await writeFile(join(staging, MARKER_NAME), `${JSON.stringify(markerFor(host), null, 2)}\n`, {
502
676
  encoding: "utf8",
503
677
  mode: 0o600,
504
678
  });
505
- await verifyExtractedPackage(plugin);
679
+ await verifyExtractedPackage(plugin, host);
506
680
  return staging;
507
681
  } catch (error) {
508
682
  await rm(staging, { recursive: true, force: true });
@@ -510,17 +684,38 @@ async function buildStagingTree(parent, pluginSource) {
510
684
  }
511
685
  }
512
686
 
513
- function entryRoot(entry) {
687
+ function entryRoot(entry, host) {
514
688
  if (entry === null) {
515
689
  return null;
516
690
  }
517
- if (typeof entry.root !== "string" || entry.root.trim().length === 0) {
518
- fail(`Codex marketplace ${MARKETPLACE_NAME} has no usable root`);
691
+ const value = host === "claude" ? entry.path ?? entry.installLocation : entry.root;
692
+ if (typeof value !== "string" || value.trim().length === 0) {
693
+ fail(`${host} marketplace ${MARKETPLACE_NAME} has no usable root`);
694
+ }
695
+ const resolved = resolve(value);
696
+ try {
697
+ // Host CLIs may report an equivalent canonical path even when the user
698
+ // configured a symlinked home (for example /var versus /private/var on
699
+ // macOS). Compare canonical existing paths without weakening ownership
700
+ // checks for missing or unmanaged roots.
701
+ return realpathSync(resolved);
702
+ } catch {
703
+ return resolved;
519
704
  }
520
- return resolve(entry.root);
521
705
  }
522
706
 
523
- function removeRegistration(entry, state) {
707
+ function removeRegistration(host, entry, state) {
708
+ if (host === "claude") {
709
+ if (state.kind === "installed") {
710
+ run(claudeBinary(), ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]);
711
+ }
712
+ if (entry !== null) {
713
+ run(claudeBinary(), [
714
+ "plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user",
715
+ ]);
716
+ }
717
+ return;
718
+ }
524
719
  if (state.kind === "installed") {
525
720
  runCodexJson(["plugin", "remove", PLUGIN_ID, "--json"]);
526
721
  }
@@ -529,7 +724,14 @@ function removeRegistration(entry, state) {
529
724
  }
530
725
  }
531
726
 
532
- function addRegistration(root, installPlugin) {
727
+ function addRegistration(host, root, installPlugin) {
728
+ if (host === "claude") {
729
+ run(claudeBinary(), ["plugin", "marketplace", "add", root, "--scope", "user"]);
730
+ if (installPlugin) {
731
+ run(claudeBinary(), ["plugin", "install", PLUGIN_ID, "--scope", "user"]);
732
+ }
733
+ return null;
734
+ }
533
735
  runCodexJson(["plugin", "marketplace", "add", root, "--json"]);
534
736
  if (installPlugin) {
535
737
  return runCodexJson(["plugin", "add", PLUGIN_ID, "--json"]);
@@ -537,10 +739,10 @@ function addRegistration(root, installPlugin) {
537
739
  return null;
538
740
  }
539
741
 
540
- function removeRegistrationBestEffort() {
742
+ function removeRegistrationBestEffort(host) {
541
743
  const entry = (() => {
542
744
  try {
543
- return marketplaceEntry();
745
+ return marketplaceEntry(host);
544
746
  } catch {
545
747
  return null;
546
748
  }
@@ -549,36 +751,60 @@ function removeRegistrationBestEffort() {
549
751
  return;
550
752
  }
551
753
  try {
552
- const state = pluginState();
754
+ const state = pluginState(host);
553
755
  if (state.kind === "installed") {
554
- run(codexBinary(), ["plugin", "remove", PLUGIN_ID, "--json"], { allowFailure: true });
756
+ const binary = host === "claude" ? claudeBinary() : codexBinary();
757
+ const args =
758
+ host === "claude"
759
+ ? ["plugin", "uninstall", PLUGIN_ID, "--scope", "user"]
760
+ : ["plugin", "remove", PLUGIN_ID, "--json"];
761
+ run(binary, args, { allowFailure: true });
555
762
  }
556
763
  } catch {
557
764
  // Rollback continues with marketplace cleanup.
558
765
  }
559
- run(codexBinary(), ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--json"], {
560
- allowFailure: true,
561
- });
766
+ const binary = host === "claude" ? claudeBinary() : codexBinary();
767
+ const args =
768
+ host === "claude"
769
+ ? ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--scope", "user"]
770
+ : ["plugin", "marketplace", "remove", MARKETPLACE_NAME, "--json"];
771
+ run(binary, args, { allowFailure: true });
562
772
  }
563
773
 
564
- async function installVerifiedPackage(pluginSource, action) {
565
- requireCodex();
566
- const paths = managedPaths();
774
+ async function recoveryStep(label, action) {
775
+ try {
776
+ await action();
777
+ return true;
778
+ } catch (recoveryError) {
779
+ const detail =
780
+ recoveryError instanceof Error ? recoveryError.message : String(recoveryError);
781
+ console.error(`warning: rollback step failed (${label}): ${detail}`);
782
+ return false;
783
+ }
784
+ }
785
+
786
+ async function installVerifiedPackage(pluginSource, action, host) {
787
+ requireHost(host);
788
+ if (host === "claude") {
789
+ requireNoLegacyClaudeInstallation();
790
+ }
791
+ const paths = managedPaths(host);
567
792
  await mkdir(paths.parent, { recursive: true, mode: 0o700 });
568
793
 
569
- const previousEntry = marketplaceEntry();
570
- if (previousEntry !== null && entryRoot(previousEntry) !== paths.root) {
794
+ const previousEntry = marketplaceEntry(host);
795
+ if (previousEntry !== null && entryRoot(previousEntry, host) !== paths.root) {
571
796
  fail(
572
- `marketplace ${MARKETPLACE_NAME} is already registered at ${entryRoot(previousEntry)}; ` +
797
+ `marketplace ${MARKETPLACE_NAME} is already registered at ${entryRoot(previousEntry, host)}; ` +
573
798
  "refusing to overwrite an unmanaged location",
574
799
  );
575
800
  }
576
- const previousState = previousEntry === null ? { kind: "missing", version: null } : pluginState();
801
+ const previousState =
802
+ previousEntry === null ? { kind: "missing", version: null } : pluginState(host);
577
803
  if (await exists(paths.root)) {
578
- await requireOwnedRoot(paths.root);
804
+ await requireOwnedRoot(paths.root, host);
579
805
  }
580
806
 
581
- const staging = await buildStagingTree(paths.parent, pluginSource);
807
+ const staging = await buildStagingTree(paths.parent, pluginSource, host);
582
808
  const backup = join(paths.parent, `.opensocrates.backup-${randomUUID()}`);
583
809
  let registrationRemoved = false;
584
810
  let backupCreated = false;
@@ -586,7 +812,7 @@ async function installVerifiedPackage(pluginSource, action) {
586
812
  try {
587
813
  if (previousEntry !== null) {
588
814
  registrationRemoved = true;
589
- removeRegistration(previousEntry, previousState);
815
+ removeRegistration(host, previousEntry, previousState);
590
816
  }
591
817
  if (await exists(paths.root)) {
592
818
  await rename(paths.root, backup);
@@ -594,40 +820,70 @@ async function installVerifiedPackage(pluginSource, action) {
594
820
  }
595
821
  await rename(staging, paths.root);
596
822
  newRootActive = true;
597
- const result = addRegistration(paths.root, true);
598
- const state = pluginState();
823
+ const result = addRegistration(host, paths.root, true);
824
+ const state = pluginState(host);
599
825
  if (
600
- result?.pluginId !== PLUGIN_ID ||
601
- result?.version !== PRODUCT_VERSION ||
826
+ (host === "codex" &&
827
+ (result?.pluginId !== PLUGIN_ID || result?.version !== PRODUCT_VERSION)) ||
602
828
  state.kind !== "installed" ||
603
829
  state.version !== PRODUCT_VERSION
604
830
  ) {
605
- fail("Codex did not confirm the expected installed plugin and version");
831
+ fail(`${host} did not confirm the expected installed plugin and version`);
606
832
  }
607
833
  if (backupCreated) {
608
- await requireOwnedRoot(backup);
834
+ await requireOwnedRoot(backup, host);
609
835
  await rm(backup, { recursive: true });
610
836
  }
611
837
  const verb = action === "update" ? "updated" : "installed";
612
838
  console.log(`OpenSocrates ${PRODUCT_VERSION} ${verb} successfully.`);
613
839
  console.log(`Managed marketplace: ${paths.root}`);
614
- console.log("Start a new Codex task to load the updated skills and hooks.");
840
+ console.log(
841
+ host === "claude"
842
+ ? "Start a new Claude Code or Cowork task to load the updated skills and hooks."
843
+ : "Start a new Codex task to load the updated skills and hooks.",
844
+ );
615
845
  } catch (error) {
616
- removeRegistrationBestEffort();
846
+ // Every recovery stage is independently guarded: one failing stage must
847
+ // never prevent the later stages from running, because the stage that
848
+ // restores the user's previous installation comes last.
849
+ let restored = false;
850
+ await recoveryStep("unregister the failed installation", async () => {
851
+ removeRegistrationBestEffort(host);
852
+ });
617
853
  if (newRootActive && (await exists(paths.root))) {
618
- await requireOwnedRoot(paths.root);
619
- await rm(paths.root, { recursive: true });
854
+ await recoveryStep("remove the failed installation root", async () => {
855
+ // This run created paths.root by renaming staging into place, so its
856
+ // provenance is known directly and the ownership marker is redundant
857
+ // here. Requiring the marker would let a host that corrupted or
858
+ // removed it while failing block recovery entirely, because the
859
+ // surviving directory then also blocks the backup rename below.
860
+ // Roots this run did not create remain guarded by requireOwnedRoot
861
+ // before staging begins, and the backup restore below still verifies
862
+ // ownership.
863
+ await rm(paths.root, { recursive: true, force: true });
864
+ });
620
865
  }
621
866
  if (backupCreated && (await exists(backup))) {
622
- await requireOwnedRoot(backup);
623
- await rename(backup, paths.root);
624
- }
625
- if (registrationRemoved && previousEntry !== null) {
626
- try {
627
- addRegistration(paths.root, previousState.kind === "installed");
628
- } catch (rollbackError) {
629
- fail(`${error.message}; rollback also failed: ${rollbackError.message}`);
630
- }
867
+ restored = await recoveryStep("restore the previous installation", async () => {
868
+ await requireOwnedRoot(backup, host);
869
+ await rename(backup, paths.root);
870
+ });
871
+ }
872
+ if (registrationRemoved && previousEntry !== null && (restored || !backupCreated)) {
873
+ await recoveryStep("re-register the previous installation", async () => {
874
+ addRegistration(host, paths.root, previousState.kind === "installed");
875
+ });
876
+ }
877
+ if (backupCreated && !restored) {
878
+ console.error(
879
+ "error: the previous OpenSocrates installation could not be restored automatically.",
880
+ );
881
+ console.error(`error: your previous files are preserved at: ${backup}`);
882
+ console.error(
883
+ `error: remove ${paths.root} if it still exists, move the preserved directory ` +
884
+ `to ${paths.root}, then rerun ` +
885
+ `\`opensocrates install --host ${host}\`.`,
886
+ );
631
887
  }
632
888
  throw error;
633
889
  } finally {
@@ -637,24 +893,27 @@ async function installVerifiedPackage(pluginSource, action) {
637
893
  }
638
894
  }
639
895
 
640
- async function showStatus() {
641
- requireCodex();
642
- const paths = managedPaths();
643
- const entry = marketplaceEntry();
896
+ async function showStatus(host) {
897
+ requireHost(host);
898
+ if (host === "claude") {
899
+ warnLegacyClaudeInstallation();
900
+ }
901
+ const paths = managedPaths(host);
902
+ const entry = marketplaceEntry(host);
644
903
  if (entry === null) {
645
904
  if (await exists(paths.root)) {
646
- await requireOwnedRoot(paths.root);
905
+ await requireOwnedRoot(paths.root, host);
647
906
  console.log(`OpenSocrates files are present but not registered: ${paths.root}`);
648
907
  } else {
649
908
  console.log("OpenSocrates is not installed.");
650
909
  }
651
910
  return;
652
911
  }
653
- if (entryRoot(entry) !== paths.root) {
654
- fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry)}`);
912
+ if (entryRoot(entry, host) !== paths.root) {
913
+ fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry, host)}`);
655
914
  }
656
- await requireOwnedRoot(paths.root);
657
- const state = pluginState();
915
+ await requireOwnedRoot(paths.root, host);
916
+ const state = pluginState(host);
658
917
  if (state.kind === "installed") {
659
918
  console.log(`OpenSocrates ${state.version ?? "unknown"} is installed.`);
660
919
  } else if (state.kind === "available") {
@@ -664,20 +923,25 @@ async function showStatus() {
664
923
  }
665
924
  }
666
925
 
667
- async function removeInstalled() {
668
- requireCodex();
669
- const paths = managedPaths();
670
- const entry = marketplaceEntry();
671
- if (entry !== null && entryRoot(entry) !== paths.root) {
672
- fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry)}`);
926
+ async function removeInstalled(host) {
927
+ requireHost(host);
928
+ if (host === "claude") {
929
+ // Warn only. A legacy registration must never block removal of the root
930
+ // this installer owns, and removal never touches the legacy installation.
931
+ warnLegacyClaudeInstallation();
932
+ }
933
+ const paths = managedPaths(host);
934
+ const entry = marketplaceEntry(host);
935
+ if (entry !== null && entryRoot(entry, host) !== paths.root) {
936
+ fail(`OpenSocrates is registered at an unmanaged location: ${entryRoot(entry, host)}`);
673
937
  }
674
- const state = entry === null ? { kind: "missing", version: null } : pluginState();
675
- removeRegistration(entry, state);
938
+ const state = entry === null ? { kind: "missing", version: null } : pluginState(host);
939
+ removeRegistration(host, entry, state);
676
940
  if (await exists(paths.root)) {
677
- await requireOwnedRoot(paths.root);
941
+ await requireOwnedRoot(paths.root, host);
678
942
  await rm(paths.root, { recursive: true });
679
943
  }
680
- console.log("OpenSocrates was removed from Codex.");
944
+ console.log(`OpenSocrates was removed from ${host === "claude" ? "Claude" : "Codex"}.`);
681
945
  }
682
946
 
683
947
  export async function main(argv = process.argv.slice(2)) {
@@ -687,11 +951,11 @@ export async function main(argv = process.argv.slice(2)) {
687
951
  return 0;
688
952
  }
689
953
  if (options.action === "status") {
690
- await showStatus();
954
+ await showStatus(options.host);
691
955
  return 0;
692
956
  }
693
957
  if (options.action === "remove") {
694
- await removeInstalled();
958
+ await removeInstalled(options.host);
695
959
  return 0;
696
960
  }
697
961
  if (
@@ -712,7 +976,7 @@ export async function main(argv = process.argv.slice(2)) {
712
976
  if (options.action === "verify") {
713
977
  return 0;
714
978
  }
715
- await installVerifiedPackage(prepared.pluginRoot, options.action);
979
+ await installVerifiedPackage(prepared.pluginRoot, options.action, options.host);
716
980
  return 0;
717
981
  } finally {
718
982
  await rm(prepared.scratch, { recursive: true, force: true });