@consiliency/agent-board-cli 1.25.0 → 1.27.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.
@@ -2,9 +2,11 @@
2
2
  // versions this cli build baked (IF-0-SETUP-1, IF-0-SETUP-4).
3
3
  //
4
4
  // harness how setup's own exact check
5
- // claude `claude plugin marketplace add …@<releaseTag>` our plugin row's version = the
6
- // + `install` (or `update` over an older one) baked mcp version and its ref = the
7
- // baked release tag
5
+ // claude materialise the packaged marketplace into our plugin row's version = the
6
+ // `claudeMarketplaceDir` (per user), then baked mcp version, the scope's
7
+ // `claude plugin marketplace add <that dir>` marketplace source is that
8
+ // + `install` (or `update` over an older one) directory, and its content is
9
+ // byte-equal to the packaged copy
8
10
  // pi `pi install [-l] npm:@consiliency/agent-board-pi@<v>` exactly that spec, once
9
11
  // opencode `opencode plugin @consiliency/agent-board-opencode@<v> [-g]` exactly that spec, once
10
12
  // codex `[mcp_servers.agent-board]` in config.toml (writer) the stanza deep-equals ours
@@ -19,19 +21,20 @@
19
21
  // Every `reason` is an authored code. File content (a server name, a path) only reaches the
20
22
  // human `fix`, and a server name only when it is a plain token.
21
23
  import { spawn } from "node:child_process";
22
- import { constants as FS, accessSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync } from "node:fs";
24
+ import { randomBytes } from "node:crypto";
25
+ import { constants as FS, accessSync, chmodSync, lstatSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
23
26
  import * as fsp from "node:fs/promises";
24
27
  import { homedir } from "node:os";
25
28
  import { basename, delimiter, dirname, isAbsolute, join } from "node:path";
26
29
  import { normalizeKeyReference } from "@consiliency/agent-board-client";
30
+ import { resolveMarketplaceDir } from "./assets.js";
31
+ import { MARKETPLACE_MANIFEST_KEY, claudeMarketplaceDir, compareVersions, describeTrustProblem, isOurMarketplacePath, pluginVersionIn, readTree, resolveTrustChain, sameTree, } from "./claude_marketplace.js";
27
32
  import { ConfigEditRefused, upsertJsonMember, upsertTomlTable } from "./config_edit.js";
28
33
  import { REPORTABLE_TOKEN } from "./doctor.js";
29
- import { CLAUDE_PLUGIN_ID, CLAUDE_PLUGIN_MARKETPLACE, CLAUDE_PLUGIN_MCP_PACKAGE, CLAUDE_PLUGIN_SERVER, HARNESS_CONFIG_SCOPES, OPENCODE_PLUGIN_PACKAGE, PI_PACKAGE, claudeProjectKey, judgeMcpEntry, nodeRegistrationFs, own, packageIdentity, parseRestrictedToml, readClaudePluginScopes, readScopes, } from "./doctor_scopes.js";
34
+ import { CLAUDE_PLUGIN_ID, CLAUDE_PLUGIN_MARKETPLACE, CLAUDE_PLUGIN_MCP_PACKAGE, CLAUDE_PLUGIN_SERVER, HARNESS_CONFIG_SCOPES, OPENCODE_PLUGIN_PACKAGE, PI_PACKAGE, claudeMarketplaceDeclarations, claudeProjectKey, judgeMcpEntry, nodeRegistrationFs, own, packageIdentity, parseRestrictedToml, readClaudePluginScopes, readScopes, } from "./doctor_scopes.js";
30
35
  export const SETUP_SCOPES = Object.freeze(["user", "local"]);
31
36
  /** The server name setup writes; the same name the claude plugin registers. */
32
37
  export const BOARD_SERVER_NAME = CLAUDE_PLUGIN_SERVER;
33
- /** `Consiliency/message-board`: the marketplace source, pinned to the baked release tag. */
34
- export const CLAUDE_MARKETPLACE_REPO = "Consiliency/message-board";
35
38
  /**
36
39
  * What codex hands an MCP child and nothing else (`process_ancestry.ts`, measured 2026-09-18 on
37
40
  * codex 0.155.0, across three configurations including `shell_environment_policy.inherit=all`).
@@ -167,8 +170,8 @@ const OWNED_ENTRY_KEYS = new Set(["command", "args", "env"]);
167
170
  export const OWNED_ENV_NAMES = new Set([
168
171
  ...CODEX_PATH_INPUTS, ...KEYCHAIN_CHILD_INPUTS, "SSH_AUTH_SOCK", "MESSAGE_BOARD_AGENT_SOCKET_PATH",
169
172
  ]);
170
- function scopeContext(env, cwd, fs, claudeManagedDir) {
171
- return { cwd, home: homeOf(env), env, fs, claudeManagedDir };
173
+ function scopeContext(env, cwd, fs, claudeManagedDir, extra = {}) {
174
+ return { cwd, home: homeOf(env), env, fs, claudeManagedDir, ...extra };
172
175
  }
173
176
  const row = (harness, name) => {
174
177
  const found = HARNESS_CONFIG_SCOPES[harness].find((candidate) => candidate.name === name);
@@ -553,7 +556,11 @@ function claudeScopeInstall(ctx, scope) {
553
556
  && (scope === "user" || (typeof own(candidate, "projectPath") === "string" && real(own(candidate, "projectPath")) === real(ctx.cwd))))
554
557
  : undefined;
555
558
  const settings = json(cliWriteTargets("claude", scope, ctx)[0]);
556
- const ref = own(own(own(own(settings, "extraKnownMarketplaces"), CLAUDE_PLUGIN_MARKETPLACE), "source"), "ref");
559
+ // This scope's declared source must be exactly our per-user directory (#549): a github ref (a
560
+ // fleet host installed before it) or any other directory is migrated by re-declaring it.
561
+ const source = own(own(own(settings, "extraKnownMarketplaces"), CLAUDE_PLUGIN_MARKETPLACE), "source");
562
+ const path = own(source, "path");
563
+ const directory = own(source, "source") === "directory" && isOurMarketplacePath(path, ctx.env, ctx.home);
557
564
  const version = own(row, "version");
558
565
  return {
559
566
  row: row !== undefined,
@@ -561,28 +568,242 @@ function claudeScopeInstall(ctx, scope) {
561
568
  enabled: own(own(settings, "enabledPlugins"), CLAUDE_PLUGIN_ID) === true,
562
569
  // The user turned it off at this scope (`claude plugin disable`): setup never turns it back on.
563
570
  disabled: own(own(settings, "enabledPlugins"), CLAUDE_PLUGIN_ID) === false,
564
- ref: typeof ref === "string" ? ref : null,
571
+ directory,
565
572
  };
566
573
  }
567
574
  /**
568
- * Setup's exact check for claude: our plugin, at the baked version, from the baked release tag,
569
- * effective here AND installed and enabled at the REQUESTED scope from that tag.
575
+ * Setup's exact check for claude: our plugin, at the baked version, from our per-user directory,
576
+ * effective here AND installed and enabled at the REQUESTED scope from that directory, with no layer
577
+ * setup may write (user, local) still declaring the marketplace another way, and our directory
578
+ * judged sound by doctor's own reader. `rewrote`: setup has just rewritten the directory because its
579
+ * content differed from the packaged bytes, so what Claude Code installed from it is not what this
580
+ * build ships — never exact.
570
581
  */
571
- export function claudePluginExact(ctx, assets, scope) {
582
+ export function claudePluginExact(ctx, assets, scope, rewrote = false) {
572
583
  const read = readClaudePluginScopes(ctx);
573
584
  const summary = read.summary;
574
585
  const atScope = claudeScopeInstall(ctx, scope);
575
- const effective = summary.installed && summary.version === assets.mcpVersion && summary.sourceRef === assets.releaseTag && !read.failures.includes("plugin_stanza_mismatch");
586
+ const staleLayers = [...new Set(claudeMarketplaceDeclarations(ctx)
587
+ .filter((declaration) => !declaration.directory && (declaration.layer === "user" || declaration.layer === "local"))
588
+ .map((declaration) => declaration.layer))];
589
+ // Round 3 (C2): every row of ours setup can reach (user, and local for this cwd) must be at the baked
590
+ // version. A local row installed from the github ref is the FIRST applicable row in its project, so
591
+ // leaving it old leaves that project on the old plugin; setup updates it, and is not exact before.
592
+ const rowLayers = SETUP_SCOPES.filter((layer) => claudeScopeInstall(ctx, layer).row);
593
+ const staleRows = rowLayers.filter((layer) => claudeScopeInstall(ctx, layer).version !== assets.mcpVersion);
594
+ // The WINNING declaration must be our directory too: the disabled path returns on `pinned` alone.
595
+ const sourceSound = summary.sourceKind === "directory" && read.sourceUndetermined.length === 0
596
+ && !read.failures.some((failure) => failure.startsWith("plugin_source_"));
597
+ const effective = summary.installed && summary.version === assets.mcpVersion && !read.failures.includes("plugin_stanza_mismatch");
598
+ const pinned = atScope.row && atScope.version === assets.mcpVersion && atScope.directory && !rewrote && sourceSound && staleLayers.length === 0 && staleRows.length === 0;
576
599
  return {
577
- exact: effective && atScope.row && atScope.version === assets.mcpVersion && atScope.enabled && atScope.ref === assets.releaseTag,
600
+ exact: effective && pinned && atScope.enabled,
578
601
  installedAtScope: atScope.row,
579
602
  enabledAtScope: atScope.row && atScope.enabled,
580
603
  disabledAtScope: atScope.row && atScope.disabled,
581
- pinnedAtScope: atScope.row && atScope.version === assets.mcpVersion && atScope.ref === assets.releaseTag,
604
+ pinnedAtScope: pinned,
582
605
  foreign: read.failures.includes("foreign_board_plugin"),
583
606
  unparseable: read.unparseable.length > 0 || read.untrusted,
607
+ staleLayers,
608
+ rowLayers,
609
+ staleRows,
584
610
  };
585
611
  }
612
+ const pinOf = (stats) => ({ dev: stats.dev, ino: stats.ino, uid: stats.uid });
613
+ /** The path now, lstat'd: still the same real directory (same dev/ino/owner), never a symlink. */
614
+ function samePin(path, pin) {
615
+ let stats;
616
+ try {
617
+ stats = lstatSync(path);
618
+ }
619
+ catch {
620
+ return pin === null;
621
+ }
622
+ return pin !== null && stats.isDirectory() && !stats.isSymbolicLink() && stats.dev === pin.dev && stats.ino === pin.ino && stats.uid === pin.uid;
623
+ }
624
+ /** The resolver's visited chain up to and including the parent: every directory entered, by identity. */
625
+ const chainKey = (visited) => visited.filter((entry) => entry.role !== "dir").map((entry) => `${entry.path}\u0000${entry.dev}:${entry.ino}`).join("\n");
626
+ const lstatOrNull = (path) => {
627
+ try {
628
+ return lstatSync(path);
629
+ }
630
+ catch {
631
+ return null;
632
+ }
633
+ };
634
+ const SIBLING = /^\.claude-marketplace\.(tmp|old)-/;
635
+ /**
636
+ * Make `claudeMarketplaceDir` hold exactly the packaged marketplace. It must be permanent (a
637
+ * directory-sourced plugin whose directory is gone fails to load: measured), so it is never the
638
+ * npx cache.
639
+ *
640
+ * Trust (B3): `checkTrustChain`, shared with doctor, over every existing path from `/` down. Our own
641
+ * `agent-board` or `claude-marketplace` directory that is group/world-writable is TIGHTENED to 0700
642
+ * rather than refused: setup owns both, nothing else is meant to write there, and a refusal would
643
+ * only ask the user to run the chmod setup can run; anything else untrusted is refused
644
+ * (`plugin_dir_untrusted`, naming the path). After `mkdir` the chain is judged again, and the parent
645
+ * and directory are pinned by dev/ino/owner; the pins are re-checked before the swap and before the
646
+ * old copy is removed, so a path swapped after the check is refused rather than written through or
647
+ * deleted through.
648
+ *
649
+ * Swap (S1): a sibling temp directory is built and renamed in; the old one is renamed aside first
650
+ * (Node cannot exchange two directories atomically, so the path is briefly absent). If the second
651
+ * rename fails, the old copy is renamed back. A failed removal of the old copy is a warning, not a
652
+ * failure. Stale temp/old siblings of ours from a crashed run are removed first.
653
+ */
654
+ function materializeMarketplace(ctx, scopeCtx) {
655
+ let packagedDir;
656
+ try {
657
+ packagedDir = ctx.marketplaceSource ?? resolveMarketplaceDir();
658
+ }
659
+ catch {
660
+ packagedDir = "";
661
+ }
662
+ const packaged = packagedDir === "" ? null : readTree(packagedDir);
663
+ // The key is `/`-separated on every OS (S5): `readTree` never uses the platform separator.
664
+ if (packaged === null || packaged.kind !== "ok" || !packaged.files.has(MARKETPLACE_MANIFEST_KEY)) {
665
+ return failed("marketplace_unpackaged", "this cli build carries no claude-marketplace/ (the build did not copy it): reinstall @consiliency/agent-board-cli, then re-run setup");
666
+ }
667
+ const dir = claudeMarketplaceDir(ctx.env, scopeCtx.home);
668
+ const parent = dirname(dir);
669
+ const uid = scopeCtx.fs.getuid();
670
+ // win32 has no uid (getuid() is -1) or POSIX mode: `checkTrustChain` then keeps only the symlink rule (S5).
671
+ const platform = process.platform;
672
+ const notes = [];
673
+ const untrusted = (detail) => failed("plugin_dir_untrusted", `${detail}; setup installs the Claude Code plugin from ${dir} and never follows or takes over what it did not make: fix or remove it (or set XDG_DATA_HOME to a directory you own), then re-run setup`);
674
+ let pinnedChain = "";
675
+ const trust = () => resolveTrustChain(dir, { ...ctx.marketplaceTrust, uid, platform });
676
+ const judge = () => {
677
+ for (let attempt = 0; attempt < 3; attempt++) {
678
+ const { problem, visited } = trust();
679
+ if (problem === null) {
680
+ pinnedChain = chainKey(visited);
681
+ return null;
682
+ }
683
+ if (!problem.tightenable)
684
+ return untrusted(describeTrustProblem(problem));
685
+ chmodSync(problem.path, 0o700);
686
+ if (!notes.includes("marketplace_dir_tightened"))
687
+ notes.push("marketplace_dir_tightened");
688
+ }
689
+ return untrusted(`${parent} could not be made private`);
690
+ };
691
+ const first = judge();
692
+ if (first)
693
+ return first;
694
+ // Leftovers of a crashed run: ours (owner, a real directory) only.
695
+ if (platform !== "win32" && lstatOrNull(parent)?.isDirectory()) {
696
+ for (const name of readdirSync(parent)) {
697
+ const stats = SIBLING.test(name) ? lstatOrNull(join(parent, name)) : null;
698
+ if (!(stats && stats.isDirectory() && !stats.isSymbolicLink() && stats.uid === uid))
699
+ continue;
700
+ try {
701
+ rmSync(join(parent, name), { recursive: true, force: true });
702
+ }
703
+ catch {
704
+ // `force` suppresses only ENOENT. A copy holding another uid's entry (the tree round 5 rebuilds)
705
+ // or a 0500 subdirectory cannot be removed: a note, as when the swap's own cleanup fails, never a throw.
706
+ if (!notes.includes("marketplace_old_copy_left"))
707
+ notes.push("marketplace_old_copy_left");
708
+ }
709
+ }
710
+ }
711
+ // Round 5: an entry inside that another uid owns or can write is NOT equal, however its bytes read:
712
+ // it is rewritten below through the same swap, with safe modes.
713
+ const current = readTree(dir, { uid, platform, lstat: ctx.marketplaceTrust?.lstat });
714
+ if (current.kind === "ok" && sameTree(current.files, packaged.files))
715
+ return { dir, rewrote: false, notes };
716
+ // Never a silent downgrade of a TRUSTED directory a newer cli wrote (round 4). An untrusted tree never
717
+ // reaches here as `ok` (round 5): its version is untrusted too, so it is rebuilt like any other.
718
+ const skew = current.kind === "ok" ? compareVersions(pluginVersionIn(current.files), pluginVersionIn(packaged.files)) : null;
719
+ if (skew !== null && skew > 0) {
720
+ return failed("plugin_dir_newer", `${dir} holds a newer agent-board plugin (${pluginVersionIn(current.kind === "ok" ? current.files : new Map()) ?? "?"}) than this cli ships (${pluginVersionIn(packaged.files) ?? "?"}); setup does not downgrade it: run the newer cli's setup (npx -y @consiliency/agent-board-cli@latest setup --harness claude)`);
721
+ }
722
+ const suffix = `${process.pid}-${randomBytes(4).toString("hex")}`;
723
+ const temp = join(parent, `.claude-marketplace.tmp-${suffix}`);
724
+ const old = join(parent, `.claude-marketplace.old-${suffix}`);
725
+ const paths = { parent, dir, temp, old };
726
+ try {
727
+ // Every directory setup creates is mkdir'd 0700; a umask can only narrow that, never widen it.
728
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
729
+ }
730
+ catch {
731
+ return failed("plugin_dir_unwritable", `setup could not create ${parent}: check that its parent is writable by you, then re-run setup`);
732
+ }
733
+ // Re-judge now that the parent exists, then pin it and the directory for the rest of the run.
734
+ const second = judge();
735
+ if (second)
736
+ return second;
737
+ // The pins: every directory the resolver entered up to the parent (round 4), and the directory.
738
+ const chainPin = pinnedChain;
739
+ const sameChain = () => {
740
+ const again = trust();
741
+ return again.problem === null && chainKey(again.visited) === chainPin;
742
+ };
743
+ const dirStats = lstatOrNull(dir);
744
+ const dirPin = dirStats ? pinOf(dirStats) : null;
745
+ const changed = () => untrusted(`${parent} or ${dir} changed while setup was writing it`);
746
+ try {
747
+ mkdirSync(temp, { mode: 0o700 });
748
+ for (const [path, file] of packaged.files) {
749
+ const target = join(temp, ...path.split("/"));
750
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
751
+ writeFileSync(target, file.bytes, { flag: "wx", mode: 0o600 });
752
+ // Private modes (round 5): 0700 or 0600 — the exec bit, which equality compares, is kept.
753
+ chmodSync(target, file.executable ? 0o700 : 0o600);
754
+ }
755
+ }
756
+ catch {
757
+ if (sameChain())
758
+ rmSync(temp, { recursive: true, force: true });
759
+ return failed("plugin_dir_unwritable", `setup could not write the Claude Code marketplace into ${parent}: check that it is writable by you, then re-run setup`);
760
+ }
761
+ ctx.marketplaceStep?.("prepared", paths);
762
+ if (!sameChain() || !samePin(dir, dirPin)) {
763
+ if (sameChain())
764
+ rmSync(temp, { recursive: true, force: true });
765
+ return changed();
766
+ }
767
+ let moved = false;
768
+ try {
769
+ if (dirPin !== null) {
770
+ renameSync(dir, old);
771
+ moved = true;
772
+ ctx.marketplaceStep?.("moved_old", paths);
773
+ }
774
+ renameSync(temp, dir);
775
+ }
776
+ catch {
777
+ // Put the old copy back (the plugin keeps loading), and drop the temp tree.
778
+ if (moved && sameChain() && lstatOrNull(dir) === null && samePin(old, dirPin)) {
779
+ try {
780
+ renameSync(old, dir);
781
+ }
782
+ catch {
783
+ /* reported below: the path may now be absent */
784
+ }
785
+ }
786
+ if (sameChain())
787
+ rmSync(temp, { recursive: true, force: true });
788
+ return failed("plugin_dir_unwritable", `setup could not swap the new Claude Code marketplace into ${dir}${lstatOrNull(dir) === null ? " and the directory is now absent" : "; the previous copy is still in place"}: check that ${parent} is writable by you, then re-run setup`);
789
+ }
790
+ ctx.marketplaceStep?.("swapped", paths);
791
+ if (moved) {
792
+ // Only what setup itself renamed aside, and only through the parent it pinned.
793
+ if (!sameChain() || !samePin(old, dirPin)) {
794
+ notes.push("marketplace_old_copy_left");
795
+ }
796
+ else {
797
+ try {
798
+ rmSync(old, { recursive: true });
799
+ }
800
+ catch {
801
+ notes.push("marketplace_old_copy_left");
802
+ }
803
+ }
804
+ }
805
+ return { dir, rewrote: true, notes };
806
+ }
586
807
  async function installClaude(ctx, scopeCtx) {
587
808
  const cli = which("claude", ctx.env);
588
809
  if (cli === null)
@@ -597,23 +818,59 @@ async function installClaude(ctx, scopeCtx) {
597
818
  return failed("existing_board_plugin", "another Claude Code plugin already registers a board server or wake hook: disable it (claude plugin disable <id>), then re-run setup");
598
819
  if (before.unparseable)
599
820
  return failed("config_unparseable", "Claude Code's plugin records do not parse or name a path outside its cache: run `claude plugin list` and repair them, then re-run setup");
600
- if (before.exact)
601
- return { status: "already", reason: "exact" };
821
+ // B1: every layer that declares our marketplace. Setup re-declares it (our own entry only) in each
822
+ // layer it may write — the requested scope, and user or local if they still declare it another
823
+ // way (a github-ref install, the documented `--scope local` one included). A layer it must not
824
+ // write (a checked-in project `.claude/settings.json`, managed settings) that would still WIN is
825
+ // refused, naming the file and the command.
826
+ const dir = claudeMarketplaceDir(ctx.env, scopeCtx.home);
827
+ const rank = { user: 0, project: 1, local: 2, managed: 3 };
828
+ const declarations = claudeMarketplaceDeclarations(scopeCtx);
829
+ const addScopes = [ctx.scope, ...before.staleLayers.filter((layer) => layer !== ctx.scope)];
830
+ const winner = [...declarations.map((declaration) => ({ ...declaration, directory: declaration.directory || addScopes.includes(declaration.layer) })),
831
+ { layer: ctx.scope, path: cliWriteTargets("claude", ctx.scope, scopeCtx)[0], directory: true }]
832
+ .sort((x, y) => rank[x.layer] - rank[y.layer]).at(-1);
833
+ if (winner && !winner.directory) {
834
+ return failed("marketplace_declared_elsewhere", winner.layer === "project"
835
+ ? `${winner.path} (checked in) declares the message-board marketplace from another source, and it outranks the ${ctx.scope} scope setup writes; setup does not edit a project's shared settings: run \`claude plugin marketplace add ${dir} --scope project\` (then commit that file), or remove the declaration, then re-run setup`
836
+ : `${winner.path} (managed settings) declares the message-board marketplace from another source, and it outranks every scope setup writes: ask your administrator to point it at ${dir} or remove it, then re-run setup`);
837
+ }
838
+ // The directory next: whether it had to be rewritten decides whether the install is exact.
839
+ const materialized = materializeMarketplace(ctx, scopeCtx);
840
+ if ("status" in materialized)
841
+ return materialized;
842
+ const { rewrote, notes } = materialized;
843
+ const withNotes = (result) => (notes.length === 0 ? result : {
844
+ ...result,
845
+ reason: `${result.reason},${notes.join(",")}`,
846
+ fix: [result.fix, notes.includes("marketplace_dir_tightened") ? `setup made ${dirname(dir)} or ${dir} private (0700): other users could write to it` : "",
847
+ notes.includes("marketplace_old_copy_left") ? `setup could not remove the previous copy beside ${dir} (.claude-marketplace.old-*): remove it by hand` : ""].filter(Boolean).join("; "),
848
+ });
849
+ const current = claudePluginExact(scopeCtx, ctx.assets, ctx.scope, rewrote);
850
+ if (current.exact)
851
+ return withNotes({ status: "already", reason: "exact" });
602
852
  // A plugin the user disabled at this scope stays disabled, as an MCP harness's off switch does:
603
853
  // pinned already → `already`; otherwise upgraded in place (`update` keeps the enable state).
604
854
  const offFix = "the board plugin is disabled at this scope (claude plugin disable), and setup keeps your choice: `claude plugin enable agent-board@message-board` to turn it on; until then doctor reports no registration";
605
- if (before.disabledAtScope && before.pinnedAtScope)
606
- return { status: "already", reason: "exact,disabled_by_user", fix: offFix };
607
- // readlink BEFORE `claude plugin … --scope` writes the settings file.
608
- const links = symlinkCheck(ctx, cliWriteTargets("claude", ctx.scope, scopeCtx));
855
+ if (current.disabledAtScope && current.pinnedAtScope)
856
+ return withNotes({ status: "already", reason: "exact,disabled_by_user", fix: offFix });
857
+ // readlink BEFORE `claude plugin … --scope` writes a settings file.
858
+ const links = symlinkCheck(ctx, addScopes.flatMap((scope) => cliWriteTargets("claude", scope, scopeCtx)));
609
859
  if (links.refusal)
610
860
  return links.refusal;
861
+ // Re-declaring under the same name with another source REPLACES it in both files and keeps the
862
+ // installed row (measured, #549): the migration from a github-ref install is this same sequence.
863
+ const steps = addScopes.map((scope) => ["plugin", "marketplace", "add", dir, "--scope", scope]);
611
864
  const scopeArgs = ["--scope", ctx.scope];
612
- const steps = [["plugin", "marketplace", "add", `${CLAUDE_MARKETPLACE_REPO}@${ctx.assets.releaseTag}`, ...scopeArgs]];
613
- // An update only over OUR row at this scope; a plugin effective here from another scope is not it.
614
- if (before.installedAtScope) {
865
+ // Update OUR rows only: at the requested scope, and (C2) at user or local wherever setup just
866
+ // re-declared the marketplace, rewrote the directory, or the row is not at the baked version — so a
867
+ // migration converges in one run. `update` keeps each scope's enable state (a user's disable too).
868
+ const updateScopes = before.rowLayers.filter((layer) => layer === ctx.scope || rewrote || addScopes.includes(layer) || before.staleRows.includes(layer));
869
+ if (updateScopes.length > 0)
615
870
  steps.push(["plugin", "marketplace", "update", CLAUDE_PLUGIN_MARKETPLACE]);
616
- steps.push(["plugin", "update", CLAUDE_PLUGIN_ID, ...scopeArgs]);
871
+ for (const layer of updateScopes)
872
+ steps.push(["plugin", "update", CLAUDE_PLUGIN_ID, "--scope", layer]);
873
+ if (before.installedAtScope) {
617
874
  // Our row with NO enable at this scope (not a disable: that is the user's choice, kept above).
618
875
  if (!before.enabledAtScope && !before.disabledAtScope)
619
876
  steps.push(["plugin", "enable", CLAUDE_PLUGIN_ID, ...scopeArgs]);
@@ -627,21 +884,27 @@ async function installClaude(ctx, scopeCtx) {
627
884
  return cliFailure;
628
885
  }
629
886
  const after = claudePluginExact(scopeCtx, ctx.assets, ctx.scope);
887
+ // Name the file that still wins with another source, if one does: the fix is there.
888
+ const winning = claudeMarketplaceDeclarations(scopeCtx).at(-1);
889
+ const where = winning && !winning.directory ? `; ${winning.path} still declares the message-board marketplace from another source` : "";
630
890
  if (before.disabledAtScope) {
631
891
  if (!after.pinnedAtScope || !after.disabledAtScope) {
632
- return failed("harness_install_unverified", `after the claude plugin update, the disabled plugin at this scope is not ${ctx.assets.mcpVersion} from ${ctx.assets.releaseTag} (or was re-enabled): run \`claude plugin list\``);
892
+ return failed("harness_install_unverified", `after the claude plugin update, the disabled plugin at this scope is not ${ctx.assets.mcpVersion} from ${dir} (or was re-enabled)${where}: run \`claude plugin list\``);
633
893
  }
634
- return { status: "done", reason: `upgraded${links.followed ? ",symlink_followed" : ""},disabled_by_user`, fix: offFix };
894
+ return withNotes({ status: "done", reason: `upgraded${links.followed ? ",symlink_followed" : ""},disabled_by_user`, fix: offFix });
635
895
  }
636
896
  if (!after.exact) {
637
- return failed("harness_install_unverified", `after the claude plugin install, the installed plugin is not ${ctx.assets.mcpVersion} from ${ctx.assets.releaseTag}: run \`claude plugin list\` and doctor --harness claude`);
897
+ return failed("harness_install_unverified", `after the claude plugin install, the installed plugin is not ${ctx.assets.mcpVersion} from ${dir}${where}: run \`claude plugin list\` and doctor --harness claude`);
638
898
  }
639
899
  const reason = before.installedAtScope ? "upgraded" : "installed";
640
- return { status: "done", reason: links.followed ? `${reason},symlink_followed` : reason };
900
+ return withNotes({ status: "done", reason: links.followed ? `${reason},symlink_followed` : reason });
641
901
  }
642
902
  /** The `harness` step for one harness and scope. Never throws for a host condition. */
643
903
  export async function installHarness(ctx) {
644
- const scopeCtx = scopeContext(ctx.env, ctx.cwd, ctx.fs ?? nodeRegistrationFs, ctx.claudeManagedDir ?? "/etc/claude-code");
904
+ const scopeCtx = scopeContext(ctx.env, ctx.cwd, ctx.fs ?? nodeRegistrationFs, ctx.claudeManagedDir ?? "/etc/claude-code", {
905
+ ...(ctx.marketplaceSource ? { claudeMarketplacePackaged: ctx.marketplaceSource } : {}),
906
+ ...(ctx.marketplaceTrust ? { claudeMarketplaceTrust: ctx.marketplaceTrust } : {}),
907
+ });
645
908
  const refusal = scopeRefusal(ctx.harness, ctx.scope, ctx);
646
909
  if (refusal)
647
910
  return failed(refusal.reason, refusal.fix);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consiliency/agent-board-cli",
3
- "version": "1.25.0",
3
+ "version": "1.27.0",
4
4
  "repository": "https://github.com/Consiliency/message-board.git",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -16,6 +16,7 @@
16
16
  },
17
17
  "files": [
18
18
  "bin",
19
+ "claude-marketplace",
19
20
  "dist",
20
21
  "keyslot",
21
22
  "LICENSE",
@@ -32,7 +33,7 @@
32
33
  "registry": "https://registry.npmjs.org"
33
34
  },
34
35
  "dependencies": {
35
- "@consiliency/agent-board-client": "1.40.0",
36
+ "@consiliency/agent-board-client": "1.41.0",
36
37
  "commander": "^14.0.1"
37
38
  },
38
39
  "license": "Apache-2.0",