akm-cli 0.10.261019 → 0.10.261020-beta

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.
@@ -14,11 +14,14 @@ import fs from "node:fs";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
16
  import { IS_WINDOWS, parseJsonc } from "../../core/common.js";
17
+ import { UsageError } from "../../core/errors.js";
17
18
  import { moveToTrash } from "../../core/trash.js";
18
19
  import { OPENCODE_V1, OPENCODE_V2, readPackageVersion, } from "../../integrations/harnesses/opencode/plugin-layout.js";
19
20
  import { opencodeCacheDir, opencodeConfigDir } from "../../integrations/harnesses/opencode/roots.js";
20
21
  import { detectOpencodeMajor, OPENCODE_BIN, resolveOpencodeBin } from "../../integrations/harnesses/opencode/version.js";
22
+ import { isExactSemver, isPrerelease } from "../../registry/semver.js";
21
23
  import { semverOrder } from "../../runtime.js";
24
+ import { ChannelWriteError, claudeChannelEdits, codexChannelEdit, codexMarketplace, editChannelJson, writeChannelEdits, } from "./plugin-channels.js";
22
25
  const MARKETPLACE = "akm-plugins";
23
26
  const PLUGIN_ID = `akm@${MARKETPLACE}`;
24
27
  const READ_TIMEOUT_MS = 30_000;
@@ -60,6 +63,13 @@ function skipped(harness, message) {
60
63
  function failed(harness, message) {
61
64
  return { harness, outcome: "failed", message };
62
65
  }
66
+ function channelWriteFailure(harness, error, before) {
67
+ return {
68
+ ...failed(harness, String(error)),
69
+ channel: error instanceof ChannelWriteError && error.writtenFiles.length ? "unknown" : before,
70
+ ...(error instanceof ChannelWriteError ? { backups: error.backups } : {}),
71
+ };
72
+ }
63
73
  /** An entry for a refresh that ran: `updated` when the installed version moved, else `current`. */
64
74
  function refreshed(harness, from, to) {
65
75
  const moved = from !== undefined && to !== undefined && from !== to;
@@ -74,48 +84,88 @@ function unknownRefresh(harness, from) {
74
84
  message: "checking needs a fetch of the akm-plugins marketplace, which --check does not do; `akm upgrade` refreshes it",
75
85
  };
76
86
  }
77
- function claudePluginVersion() {
87
+ function claudePlugins() {
78
88
  const listed = runCommand("claude", ["plugin", "list", "--json"], READ_TIMEOUT_MS);
79
89
  if (!listed.ok)
80
90
  return { error: listed };
81
91
  const plugins = parseJson(listed.stdout);
82
- const plugin = Array.isArray(plugins) ? plugins.find((p) => p?.id === PLUGIN_ID) : undefined;
83
- return { version: plugin ? String(plugin.version ?? "") : undefined };
92
+ const installations = Array.isArray(plugins)
93
+ ? plugins
94
+ .filter((p) => p?.id === PLUGIN_ID)
95
+ .map((p) => ({
96
+ version: typeof p.version === "string" ? p.version : undefined,
97
+ scope: typeof p.scope === "string" ? p.scope : undefined,
98
+ projectPath: typeof p.projectPath === "string" ? p.projectPath : undefined,
99
+ }))
100
+ : [];
101
+ return { installations };
84
102
  }
85
- function detectClaudeCode() {
103
+ function detectClaudeCode(strict = false) {
86
104
  const marketplaces = runCommand("claude", ["plugin", "marketplace", "list", "--json"], READ_TIMEOUT_MS);
87
105
  if (!marketplaces.ok) {
88
- return { entry: skipped("claude-code", marketplaces.missing ? "claude is not on PATH" : marketplaces.error) };
106
+ const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
107
+ const pluginsRoot = process.env.AKM_CLAUDE_PLUGINS_DIR ?? process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ?? path.join(configDir, "plugins");
108
+ const configured = fs.existsSync(path.join(pluginsRoot, "known_marketplaces.json"));
109
+ return {
110
+ entry: strict && (!marketplaces.missing || configured)
111
+ ? { ...failed("claude-code", marketplaces.error), channel: "unknown" }
112
+ : skipped("claude-code", marketplaces.missing ? "claude is not on PATH" : marketplaces.error),
113
+ };
89
114
  }
90
115
  const list = parseJson(marketplaces.stdout);
91
116
  const marketplace = Array.isArray(list) ? list.find((m) => m?.name === MARKETPLACE) : undefined;
92
117
  if (!marketplace)
93
118
  return { entry: skipped("claude-code", `the ${MARKETPLACE} marketplace is not configured`) };
94
- const plugin = claudePluginVersion();
119
+ const plugin = claudePlugins();
95
120
  if ("error" in plugin)
96
- return { entry: skipped("claude-code", plugin.error.error) };
97
- if (plugin.version === undefined)
121
+ return {
122
+ entry: strict
123
+ ? { ...failed("claude-code", plugin.error.error), channel: "unknown" }
124
+ : skipped("claude-code", plugin.error.error),
125
+ };
126
+ if (!plugin.installations.length)
98
127
  return { entry: skipped("claude-code", "the akm plugin is not installed") };
99
- return { version: plugin.version, ref: typeof marketplace.ref === "string" ? marketplace.ref : undefined };
128
+ return {
129
+ version: plugin.installations[0]?.version,
130
+ installations: plugin.installations,
131
+ ref: typeof marketplace.ref === "string" ? marketplace.ref : undefined,
132
+ };
100
133
  }
101
- function upgradeClaudeCode(dryRun, next) {
102
- const detected = detectClaudeCode();
134
+ function upgradeClaudeCode(dryRun, next, channel) {
135
+ const detected = detectClaudeCode(channel !== undefined);
103
136
  if ("entry" in detected)
104
137
  return detected.entry;
105
- const entry = refreshClaudeCode(dryRun, detected.version);
106
- return next ? withChannelNote(entry, detected.ref, "re-add the marketplace as `itlackey/akm-plugins@next`") : entry;
138
+ const before = refChannel(detected.ref);
139
+ let backups = [];
140
+ if (channel && !dryRun) {
141
+ try {
142
+ const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
143
+ const pluginsRoot = process.env.AKM_CLAUDE_PLUGINS_DIR ??
144
+ process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ??
145
+ path.join(configDir, "plugins");
146
+ backups = writeChannelEdits(claudeChannelEdits(configDir, pluginsRoot, channel, detected.installations.flatMap((p) => (p.projectPath ? [p.projectPath] : []))));
147
+ }
148
+ catch (error) {
149
+ return channelWriteFailure("claude-code", error, before);
150
+ }
151
+ }
152
+ const entry = refreshClaudeCode(dryRun, detected.version, channel ? detected.installations : undefined);
153
+ const noted = next && !channel ? withChannelNote(entry, detected.ref) : entry;
154
+ return channelResult(noted, before, channel, dryRun, backups);
107
155
  }
108
- function refreshClaudeCode(dryRun, from) {
156
+ function refreshClaudeCode(dryRun, from, installations) {
109
157
  if (dryRun)
110
158
  return unknownRefresh("claude-code", from);
111
159
  const marketplace = runCommand("claude", ["plugin", "marketplace", "update", MARKETPLACE], REFRESH_TIMEOUT_MS);
112
160
  if (!marketplace.ok)
113
161
  return failed("claude-code", marketplace.error);
114
- const update = runCommand("claude", ["plugin", "update", PLUGIN_ID], REFRESH_TIMEOUT_MS);
115
- if (!update.ok)
116
- return failed("claude-code", update.error);
117
- const after = claudePluginVersion();
118
- return refreshed("claude-code", from, "version" in after ? after.version : undefined);
162
+ for (const installation of installations ?? [{}]) {
163
+ const update = runCommand("claude", ["plugin", "update", PLUGIN_ID, ...(installation.scope ? ["--scope", installation.scope] : [])], REFRESH_TIMEOUT_MS, { cwd: installation.projectPath });
164
+ if (!update.ok)
165
+ return failed("claude-code", update.error);
166
+ }
167
+ const after = claudePlugins();
168
+ return refreshed("claude-code", from, "installations" in after ? after.installations[0]?.version : undefined);
119
169
  }
120
170
  // ── Codex ───────────────────────────────────────────────────────────────────
121
171
  function codexPluginVersion() {
@@ -126,29 +176,39 @@ function codexPluginVersion() {
126
176
  const plugin = parsed?.installed?.find((p) => p?.pluginId === PLUGIN_ID);
127
177
  return { version: plugin ? String(plugin.version ?? "") : undefined };
128
178
  }
129
- /** The ref `[marketplaces.akm-plugins]` follows in Codex's config.toml; undefined when it names none (the default branch). */
179
+ function codexConfigFile() {
180
+ return path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "config.toml");
181
+ }
130
182
  function codexMarketplaceRef() {
131
183
  try {
132
- const home = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
133
- const config = fs.readFileSync(path.join(home, "config.toml"), "utf8");
134
- const section = config.split(/^\[/m).find((part) => part.startsWith(`marketplaces.${MARKETPLACE}]`));
135
- return section?.match(/^ref\s*=\s*"([^"]+)"/m)?.[1];
184
+ const ref = codexMarketplace(codexConfigFile())?.ref;
185
+ return typeof ref === "string" ? ref : undefined;
136
186
  }
137
187
  catch {
138
188
  return undefined;
139
189
  }
140
190
  }
141
- function upgradeCodex(dryRun, next) {
191
+ function upgradeCodex(dryRun, next, channel) {
142
192
  const before = codexPluginVersion();
143
193
  if ("error" in before)
144
- return skipped("codex", before.error.missing ? "codex is not on PATH" : before.error.error);
194
+ return channel && (!before.error.missing || fs.existsSync(codexConfigFile()))
195
+ ? { ...failed("codex", before.error.error), channel: "unknown" }
196
+ : skipped("codex", before.error.missing ? "codex is not on PATH" : before.error.error);
145
197
  if (before.version === undefined) {
146
198
  return skipped("codex", `the akm plugin is not installed from the ${MARKETPLACE} marketplace`);
147
199
  }
200
+ const previous = refChannel(codexMarketplaceRef());
201
+ const backups = [];
202
+ if (channel && previous !== channel && !dryRun) {
203
+ try {
204
+ backups.push(...writeChannelEdits([codexChannelEdit(codexConfigFile(), channel)]));
205
+ }
206
+ catch (error) {
207
+ return channelWriteFailure("codex", error, previous);
208
+ }
209
+ }
148
210
  const entry = refreshCodex(dryRun, before.version);
149
- return next
150
- ? withChannelNote(entry, codexMarketplaceRef(), "run `codex plugin marketplace add itlackey/akm-plugins --ref next`")
151
- : entry;
211
+ return channelResult(next && !channel ? withChannelNote(entry, codexMarketplaceRef()) : entry, previous, channel, dryRun, backups);
152
212
  }
153
213
  function refreshCodex(dryRun, from) {
154
214
  if (dryRun)
@@ -192,10 +252,10 @@ export function openCodeConfigSpec(flavor) {
192
252
  const entries = config[key];
193
253
  if (!Array.isArray(entries))
194
254
  continue;
195
- for (const entry of entries) {
255
+ for (const [index, entry] of entries.entries()) {
196
256
  const spec = entrySpec(entry);
197
257
  if (spec !== undefined && namesPackage(spec, flavor.pkg))
198
- return { spec, key };
258
+ return { spec, key, file, jsonPath: [key, index, ...(Array.isArray(entry) ? [0] : [])] };
199
259
  }
200
260
  }
201
261
  }
@@ -317,7 +377,7 @@ function upgradeOpenCode(dryRun, target) {
317
377
  return failed(harness, latest.error);
318
378
  if (target.notFollowingNext)
319
379
  return skipped(harness, notFollowingNext(flavor));
320
- if (cache.version === latest.version)
380
+ if (!target.switchChannel && cache.version === latest.version)
321
381
  return { harness, outcome: "current", from: cache.version, to: latest.version };
322
382
  const base = { harness, from: cache.version, to: latest.version };
323
383
  // OpenCode 1's refresh replaces the cache the running OpenCode is using, so it waits for the exit.
@@ -350,12 +410,27 @@ function upgradeOpenCode(dryRun, target) {
350
410
  return skipped(harness, `\`${bin}\` on PATH is ${major === undefined ? "of an unknown version" : `OpenCode ${major}`}, ` +
351
411
  `not OpenCode ${flavor.major}, so it cannot refresh the ${flavor.pkg} cache; the cache is left alone`);
352
412
  }
353
- if (flavor.replacesCache) {
413
+ let backups = [];
414
+ if (target.switchChannel) {
415
+ const configured = openCodeConfigSpec(flavor);
416
+ if (!configured)
417
+ return failed(harness, "the configured plugin entry disappeared before switching its channel");
418
+ try {
419
+ backups = writeChannelEdits([editChannelJson(configured.file, configured.jsonPath, target.spec)]);
420
+ }
421
+ catch (error) {
422
+ return channelWriteFailure(harness, error, target.currentChannel ?? "unknown");
423
+ }
424
+ }
425
+ if (flavor.replacesCache && fs.existsSync(cache.dir)) {
354
426
  try {
355
427
  moveToTrash(cache.dir);
356
428
  }
357
429
  catch (error) {
358
- return failed(harness, `could not move ${cache.dir} to the trash: ${error instanceof Error ? error.message : String(error)}`);
430
+ return {
431
+ ...failed(harness, `could not move ${cache.dir} to the trash: ${error instanceof Error ? error.message : String(error)}`),
432
+ backups,
433
+ };
359
434
  }
360
435
  }
361
436
  const trashed = flavor.replacesCache ? " (the old cache is in the trash)" : "";
@@ -369,36 +444,64 @@ function upgradeOpenCode(dryRun, target) {
369
444
  refresh = runCommand(bin, flavor.refresh(target.spec), PREFETCH_TIMEOUT_MS, { cwd: workDir });
370
445
  }
371
446
  if (!refresh.ok)
372
- return failed(harness, `${refresh.error}${trashed}`);
447
+ return { ...failed(harness, `${refresh.error}${trashed}`), backups };
373
448
  }
374
449
  finally {
375
450
  fs.rmSync(workDir, { recursive: true, force: true });
376
451
  }
377
452
  const after = detectOpenCodeCache(tag, flavor);
378
453
  if (!after)
379
- return failed(harness, `opencode did not re-create the plugin cache${trashed}`);
380
- return { ...base, outcome: "updated", to: after.version ?? latest.version };
454
+ return { ...failed(harness, `opencode did not re-create the plugin cache${trashed}`), backups };
455
+ return { ...base, outcome: "updated", to: after.version ?? latest.version, ...(backups.length ? { backups } : {}) };
381
456
  }
382
457
  // ── Orchestration ───────────────────────────────────────────────────────────
383
458
  /**
384
459
  * The note `--next` adds to a marketplace harness. The channel is the ref the marketplace follows (`next` is the
385
- * prerelease branch, the default branch is stable); akm only updates that ref and cannot switch it.
460
+ * prerelease branch, the default branch is stable); only an explicit --channel switches it.
386
461
  */
387
- function withChannelNote(entry, ref, howToSwitch) {
462
+ function refChannel(ref) {
463
+ return !ref || ref === "main" || ref === "master" ? "latest" : ref === "next" ? "next" : "pinned";
464
+ }
465
+ function channelResult(entry, before, target, dryRun, backups = []) {
466
+ const changed = target !== undefined && target !== before;
467
+ return {
468
+ ...entry,
469
+ channel: changed && !dryRun ? target : before,
470
+ ...(target ? { targetChannel: target } : {}),
471
+ ...(changed
472
+ ? {
473
+ previousChannel: before,
474
+ message: `${entry.message ? `${entry.message}; ` : ""}${dryRun ? "would configure" : "configured"} channel ${before} → ${target}`,
475
+ }
476
+ : {}),
477
+ ...(backups.length ? { backups } : {}),
478
+ };
479
+ }
480
+ function withChannelNote(entry, ref) {
388
481
  if (entry.outcome === "skipped")
389
482
  return entry;
390
483
  const note = ref === "next"
391
484
  ? `--next: the ${MARKETPLACE} marketplace follows ref next, the prerelease channel`
392
- : `--next: the ${MARKETPLACE} marketplace follows ${ref ? `ref ${ref}` : "the default branch (stable)"}, ` +
393
- `not the prerelease channel; akm upgrade cannot switch it, ${howToSwitch} to follow prereleases`;
485
+ : `--next: the ${MARKETPLACE} marketplace follows ${ref ? `ref ${ref}` : "the default branch (stable)"}; use akm upgrade --channel next to switch all installed plugins`;
394
486
  return { ...entry, message: entry.message ? `${entry.message}; ${note}` : note };
395
487
  }
396
488
  export function upgradePlugins(opts) {
397
489
  return [
398
- upgradeClaudeCode(opts.dryRun, opts.next),
399
- upgradeCodex(opts.dryRun, opts.next),
400
- ...opts.openCode.map((target) => upgradeOpenCode(opts.dryRun, target)),
401
- ];
490
+ upgradeClaudeCode(opts.dryRun, opts.next, opts.channel),
491
+ upgradeCodex(opts.dryRun, opts.next, opts.channel),
492
+ ...opts.openCode.map((target) => {
493
+ const entry = upgradeOpenCode(opts.dryRun, target);
494
+ if (target.currentChannel === undefined)
495
+ return entry;
496
+ const changed = entry.channel === undefined &&
497
+ target.switchChannel &&
498
+ ((entry.backups?.length ?? 0) > 0 || entry.outcome === "updated" || entry.outcome === "current");
499
+ return channelResult(entry, entry.channel ?? target.currentChannel ?? "unknown", opts.dryRun || changed ? target.switchChannel : undefined, opts.dryRun, entry.backups);
500
+ }),
501
+ ].map((entry) => ({
502
+ ...entry,
503
+ ...(entry.channel && opts.currentChannel ? { channelMismatch: entry.channel !== opts.currentChannel } : {}),
504
+ }));
402
505
  }
403
506
  /**
404
507
  * When an OpenCode plugin is present the CLI target is the akm-cli that
@@ -469,10 +572,32 @@ function lockstepSource(targets) {
469
572
  * naming any other spec (an exact version, or `@next` on a plain run) is left
470
573
  * alone, with no lockstep.
471
574
  */
472
- function resolveOpenCodeTarget(next, flavor, configured) {
575
+ function resolveOpenCodeTarget(next, flavor, configured, channel) {
473
576
  // OpenCode 1 with no config entry behaves like a bare one: it would resolve @latest. OpenCode 2's
474
577
  // re-fetch writes the config, so its plugin is only refreshed where the config already names it.
475
- const spec = configured?.spec ?? flavor.pkg;
578
+ const currentSpec = configured?.spec ?? flavor.pkg;
579
+ const currentChannel = currentSpec === flavor.pkg || currentSpec === `${flavor.pkg}@latest`
580
+ ? "latest"
581
+ : currentSpec === nextSpec(flavor)
582
+ ? "next"
583
+ : "pinned";
584
+ const spec = channel && configured ? (channel === "next" ? nextSpec(flavor) : flavor.pkg) : currentSpec;
585
+ if (channel && configured) {
586
+ const fromSpec = currentSpec === flavor.pkg ? `${flavor.pkg}@latest` : currentSpec;
587
+ const oldDir = path.join(opencodeCacheDir(), flavor.cacheFolder(fromSpec));
588
+ const oldModules = flavor.nodeModulesDir(oldDir);
589
+ const version = oldModules ? readPackageVersion(path.join(oldModules, flavor.pkg, "package.json")) : undefined;
590
+ return {
591
+ flavor,
592
+ spec,
593
+ tag: channel,
594
+ currentChannel,
595
+ notFollowingNext: false,
596
+ switchChannel: currentChannel !== channel ? channel : undefined,
597
+ cache: { dir: path.join(opencodeCacheDir(), flavor.cacheFolder(`${flavor.pkg}@${channel}`)), version },
598
+ latest: lookupOpenCodeLatest(channel, flavor),
599
+ };
600
+ }
476
601
  const leftAlone = (skip) => ({
477
602
  flavor,
478
603
  cache: undefined,
@@ -480,6 +605,7 @@ function resolveOpenCodeTarget(next, flavor, configured) {
480
605
  tag: next ? "next" : "latest",
481
606
  spec,
482
607
  notFollowingNext: false,
608
+ currentChannel: configured ? currentChannel : undefined,
483
609
  skip,
484
610
  });
485
611
  if (flavor.globalConfigOnly) {
@@ -499,45 +625,201 @@ function resolveOpenCodeTarget(next, flavor, configured) {
499
625
  const tag = followNext ? "next" : "latest";
500
626
  const cache = detectOpenCodeCache(tag, flavor);
501
627
  const latest = cache ? lookupOpenCodeLatest(tag, flavor) : undefined;
502
- return { flavor, cache, latest, tag, spec, notFollowingNext: next && !followNext };
628
+ return {
629
+ flavor,
630
+ cache,
631
+ latest,
632
+ tag,
633
+ spec,
634
+ currentChannel: configured || cache ? currentChannel : undefined,
635
+ notFollowingNext: next && !followNext,
636
+ };
503
637
  }
504
638
  /**
505
639
  * The OpenCode plugins this host has: OpenCode 1's always, OpenCode 2's only when the global config
506
640
  * names `akm-opencode-v2` or OpenCode 2 has cached it, so a host with just OpenCode 1 sees the same
507
641
  * output as before.
508
642
  */
509
- function resolveOpenCodeTargets(next) {
510
- const targets = [resolveOpenCodeTarget(next, OPENCODE_V1, openCodeConfigSpec(OPENCODE_V1))];
643
+ function resolveOpenCodeTargets(next, channel) {
644
+ const targets = [resolveOpenCodeTarget(next, OPENCODE_V1, openCodeConfigSpec(OPENCODE_V1), channel)];
511
645
  const v2 = openCodeConfigSpec(OPENCODE_V2);
512
646
  if (v2 || openCodeV2Cached())
513
- targets.push(resolveOpenCodeTarget(next, OPENCODE_V2, v2));
647
+ targets.push(resolveOpenCodeTarget(next, OPENCODE_V2, v2, channel));
514
648
  return targets;
515
649
  }
650
+ /** Inspect the requested branch in a disposable clone before changing any installation. */
651
+ function marketplaceTargetVersion(source, channel, harness) {
652
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akm-channel-check-"));
653
+ const checkout = path.join(root, "marketplace");
654
+ try {
655
+ const cloned = runCommand("git", [
656
+ "clone",
657
+ "--depth",
658
+ "1",
659
+ "--single-branch",
660
+ ...(channel === "next" ? ["--branch", "next"] : []),
661
+ "--",
662
+ source,
663
+ checkout,
664
+ ], REFRESH_TIMEOUT_MS);
665
+ if (!cloned.ok)
666
+ throw new UsageError(`Could not check the target plugin version: ${cloned.error}`);
667
+ const catalog = parseJson(fs.readFileSync(path.join(checkout, ...(harness === "codex" ? [".agents", "plugins"] : [".claude-plugin"]), "marketplace.json"), "utf8"));
668
+ const plugin = catalog?.plugins?.find((p) => p.name === "akm");
669
+ const relative = typeof plugin?.source === "string"
670
+ ? plugin.source
671
+ : plugin?.source?.source === "local"
672
+ ? plugin.source.path
673
+ : undefined;
674
+ const local = relative ? path.resolve(checkout, relative) : undefined;
675
+ const contained = local === checkout || local?.startsWith(`${checkout}${path.sep}`);
676
+ const version = plugin?.version ??
677
+ (local && contained
678
+ ? readPackageVersion(path.join(local, harness === "codex" ? ".codex-plugin" : ".claude-plugin", "plugin.json"))
679
+ : undefined);
680
+ if (!version || !isExactSemver(version))
681
+ throw new UsageError("Could not determine the requested marketplace's akm plugin version; no channel was changed");
682
+ return version;
683
+ }
684
+ finally {
685
+ fs.rmSync(root, { recursive: true, force: true });
686
+ }
687
+ }
688
+ function preflightMarketplaceChannels(channel, allowDowngrade) {
689
+ const versions = new Map();
690
+ const verify = (harness, source, installed) => {
691
+ if (allowDowngrade)
692
+ return;
693
+ const key = `${harness}:${source}`;
694
+ let target = versions.get(key);
695
+ if (!target) {
696
+ target = marketplaceTargetVersion(source, channel, harness);
697
+ versions.set(key, target);
698
+ }
699
+ if (installed && semverOrder(target, installed) < 0)
700
+ throw new UsageError(`Switching to ${channel} would downgrade ${harness} from ${installed} to ${target}; repeat with --allow-downgrade`);
701
+ };
702
+ const claude = detectClaudeCode(true);
703
+ if ("entry" in claude && claude.entry.outcome === "failed")
704
+ throw new UsageError(`Could not inventory Claude before switching channels: ${claude.entry.message}`);
705
+ if (!("entry" in claude)) {
706
+ const configDir = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
707
+ const pluginsRoot = process.env.AKM_CLAUDE_PLUGINS_DIR ?? process.env.CLAUDE_CODE_PLUGIN_CACHE_DIR ?? path.join(configDir, "plugins");
708
+ claudeChannelEdits(configDir, pluginsRoot, channel, claude.installations.flatMap((p) => (p.projectPath ? [p.projectPath] : [])));
709
+ const known = parseJson(fs.readFileSync(path.join(pluginsRoot, "known_marketplaces.json"), "utf8"));
710
+ const source = known[MARKETPLACE]?.source;
711
+ const url = source?.source === "github" && source.repo ? `https://github.com/${source.repo}.git` : source?.url;
712
+ if (!url)
713
+ throw new UsageError("Could not determine the Claude marketplace Git source; no channel was changed");
714
+ for (const installed of claude.installations)
715
+ verify("claude", url, installed.version);
716
+ }
717
+ const codex = codexPluginVersion();
718
+ if ("error" in codex && (!codex.error.missing || fs.existsSync(codexConfigFile())))
719
+ throw new UsageError(`Could not inventory Codex before switching channels: ${codex.error.error}`);
720
+ if (!("error" in codex) && codex.version !== undefined) {
721
+ codexChannelEdit(codexConfigFile(), channel);
722
+ const source = codexMarketplace(codexConfigFile())?.source;
723
+ if (typeof source !== "string")
724
+ throw new UsageError("Could not determine the Codex marketplace Git source; no channel was changed");
725
+ verify("codex", source, codex.version);
726
+ }
727
+ }
516
728
  /** `akm upgrade`: the CLI step (held to the OpenCode plugin's akm), then the plugins. */
517
729
  export async function runUpgrade(args, currentVersion, deps) {
518
- const next = args.next === true;
730
+ if (args.channel !== undefined && args.channel !== "latest" && args.channel !== "next")
731
+ throw new UsageError("--channel must be latest or next");
732
+ if (args.next && args.channel === "latest")
733
+ throw new UsageError("--next conflicts with --channel latest");
734
+ if (args.allowDowngrade && !args.channel)
735
+ throw new UsageError("--allow-downgrade requires --channel latest or next");
736
+ const switchChannel = args.channel;
737
+ const next = switchChannel ? switchChannel === "next" : args.next === true;
519
738
  const channel = next ? "next" : "latest";
520
- const openCode = resolveOpenCodeTargets(next);
739
+ const currentChannel = isPrerelease(currentVersion) ? "next" : "latest";
740
+ const openCode = resolveOpenCodeTargets(next, switchChannel);
521
741
  const held = lockstepSource(openCode);
522
742
  const check = {
523
- ...applyLockstep(await deps.checkForUpdate(currentVersion, channel), held?.latest, held?.tag ?? "latest", held?.flavor.pkg ?? OPENCODE_V1.pkg),
743
+ ...applyLockstep(await deps.checkForUpdate(currentVersion, channel, switchChannel !== undefined), held?.latest, held?.tag ?? "latest", held?.flavor.pkg ?? OPENCODE_V1.pkg),
524
744
  channel,
745
+ currentChannel,
746
+ ...(switchChannel ? { targetChannel: switchChannel } : {}),
525
747
  };
748
+ if (switchChannel &&
749
+ held?.latest &&
750
+ !("error" in held.latest) &&
751
+ held.latest.akmCli &&
752
+ semverOrder(held.latest.akmCli, check.latestVersion) < 0) {
753
+ check.latestVersion = held.latest.akmCli;
754
+ check.updateAvailable = semverOrder(currentVersion, check.latestVersion) < 0;
755
+ }
526
756
  if (args.check) {
527
- return { mode: "check", result: { ...check, plugins: upgradePlugins({ dryRun: true, next, openCode }) } };
757
+ return {
758
+ mode: "check",
759
+ result: {
760
+ ...check,
761
+ plugins: upgradePlugins({ dryRun: true, next, openCode, channel: switchChannel, currentChannel }),
762
+ },
763
+ };
764
+ }
765
+ if (switchChannel && !args.allowDowngrade) {
766
+ if (semverOrder(check.latestVersion, currentVersion) < 0)
767
+ throw new UsageError(`Switching to ${channel} would downgrade akm from ${currentVersion} to ${check.latestVersion}; repeat with --allow-downgrade`);
768
+ for (const target of openCode) {
769
+ if (target.cache?.version &&
770
+ target.latest &&
771
+ !("error" in target.latest) &&
772
+ semverOrder(target.latest.version, target.cache.version) < 0)
773
+ throw new UsageError(`Switching to ${channel} would downgrade ${target.flavor.pkg} from ${target.cache.version} to ${target.latest.version}; repeat with --allow-downgrade`);
774
+ }
528
775
  }
776
+ if (switchChannel)
777
+ preflightMarketplaceChannels(switchChannel, args.allowDowngrade === true);
778
+ if (switchChannel && args.allowDowngrade && check.latestVersion !== currentVersion)
779
+ check.updateAvailable = true;
529
780
  const upgraded = await deps.performUpgrade(check, {
530
781
  force: args.force,
531
782
  skipPostUpgrade: args.skipPostUpgrade,
532
783
  // A package manager install must name the version, or `@latest` goes past the pin
533
784
  // (or past the prerelease that `--next` chose).
534
- ...(check.lockstep?.heldBack || (next && check.latestVersion) ? { targetVersion: check.latestVersion } : {}),
785
+ ...(switchChannel || check.lockstep?.heldBack || (next && check.latestVersion)
786
+ ? { targetVersion: check.latestVersion }
787
+ : {}),
535
788
  });
536
- const plugins = upgradePlugins({ dryRun: false, next, openCode });
537
- const result = { ...upgraded, channel, ...(check.lockstep ? { lockstep: check.lockstep } : {}), plugins };
789
+ if (switchChannel &&
790
+ (upgraded.migration?.status === "blocked" ||
791
+ upgraded.migration?.status === "failed" ||
792
+ (check.latestVersion !== currentVersion && !upgraded.upgraded))) {
793
+ return {
794
+ mode: "upgrade",
795
+ result: { ...upgraded, channel, currentChannel, targetChannel: switchChannel, plugins: [] },
796
+ failed: true,
797
+ };
798
+ }
799
+ const plugins = upgradePlugins({
800
+ dryRun: false,
801
+ next,
802
+ openCode,
803
+ channel: switchChannel,
804
+ currentChannel: upgraded.upgraded ? (isPrerelease(upgraded.newVersion) ? "next" : "latest") : currentChannel,
805
+ });
806
+ const result = {
807
+ ...upgraded,
808
+ channel,
809
+ currentChannel,
810
+ ...(switchChannel ? { targetChannel: switchChannel } : {}),
811
+ ...(check.lockstep ? { lockstep: check.lockstep } : {}),
812
+ plugins,
813
+ };
538
814
  // The install may have succeeded, but an upgrade whose migration is
539
815
  // blocked or could not run is not done, and neither is one whose plugin
540
816
  // step failed.
541
817
  const migrationFailed = upgraded.migration?.status === "blocked" || upgraded.migration?.status === "failed";
542
- return { mode: "upgrade", result, failed: migrationFailed || plugins.some((p) => p.outcome === "failed") };
818
+ const switchIncomplete = switchChannel !== undefined &&
819
+ plugins.some((p) => p.channel !== undefined && (p.channel !== switchChannel || p.outcome === "deferred" || p.outcome === "skipped"));
820
+ return {
821
+ mode: "upgrade",
822
+ result,
823
+ failed: migrationFailed || switchIncomplete || plugins.some((p) => p.outcome === "failed"),
824
+ };
543
825
  }
@@ -187,11 +187,12 @@ async function lookupNextVersion(fetchOptions) {
187
187
  /**
188
188
  * The newest release. With `channel: "next"` that includes prereleases: the
189
189
  * `next` dist-tag when it is newer than the latest stable release, else the
190
- * stable release (a prerelease older than a stable one is never a target).
190
+ * stable release. An explicit channel switch sets exactChannel to select the
191
+ * actual next tag, leaving downgrade consent to runUpgrade.
191
192
  * A binary install downloads the GitHub release tagged `v<version>`, which a
192
193
  * prerelease has too, so the version is all the install step needs.
193
194
  */
194
- export async function checkForUpdate(currentVersion, fetchOptions, channel = "latest") {
195
+ export async function checkForUpdate(currentVersion, fetchOptions, channel = "latest", exactChannel = false) {
195
196
  const installMethod = detectInstallMethod();
196
197
  const url = `https://api.github.com/repos/${REPO}/releases/latest`;
197
198
  const response = await fetchWithRetry(url, { headers: githubHeaders() }, fetchOptions);
@@ -203,7 +204,9 @@ export async function checkForUpdate(currentVersion, fetchOptions, channel = "la
203
204
  let latestVersion = latestTag.replace(/^v/, "");
204
205
  if (channel === "next") {
205
206
  const next = await lookupNextVersion(fetchOptions);
206
- if (next && (latestVersion === "" || semverOrder(latestVersion, next) < 0))
207
+ if (exactChannel && !next)
208
+ throw new Error("No next prerelease is published; no channel was changed");
209
+ if (next && (exactChannel || latestVersion === "" || semverOrder(latestVersion, next) < 0))
207
210
  latestVersion = next;
208
211
  }
209
212
  return {
@@ -52,6 +52,12 @@ export const upgradeCommand = defineJsonCommand({
52
52
  description: "Skip the post-upgrade index rebuild",
53
53
  default: false,
54
54
  },
55
+ channel: { type: "string", description: "Switch akm and every installed harness plugin to latest or next" },
56
+ "allow-downgrade": {
57
+ type: "boolean",
58
+ description: "Allow an explicit channel switch to install an older release",
59
+ default: false,
60
+ },
55
61
  next: {
56
62
  type: "boolean",
57
63
  description: "Install the newest prerelease (the @next npm tag) of akm and, if OpenCode's config names akm-opencode@next, of its plugin",
@@ -59,8 +65,15 @@ export const upgradeCommand = defineJsonCommand({
59
65
  },
60
66
  },
61
67
  async run({ args }) {
62
- const run = await runUpgrade({ check: args.check, force: args.force, skipPostUpgrade: args["skip-post-upgrade"], next: args.next }, pkgVersion, {
63
- checkForUpdate: (version, channel) => checkForUpdate(version, undefined, channel),
68
+ const run = await runUpgrade({
69
+ check: args.check,
70
+ force: args.force,
71
+ skipPostUpgrade: args["skip-post-upgrade"],
72
+ next: args.next,
73
+ channel: args.channel,
74
+ allowDowngrade: args["allow-downgrade"],
75
+ }, pkgVersion, {
76
+ checkForUpdate: (version, channel, exactChannel) => checkForUpdate(version, undefined, channel, exactChannel),
64
77
  performUpgrade: (check, opts) => performUpgrade(check, opts),
65
78
  });
66
79
  if (run.mode === "check") {