@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.
- package/claude-marketplace/.claude-plugin/marketplace.json +14 -0
- package/claude-marketplace/plugins/agent-board/.claude-plugin/plugin.json +19 -0
- package/claude-marketplace/plugins/agent-board/hooks/hooks.json +25 -0
- package/claude-marketplace/plugins/agent-board/hooks/wake-hook.sh +288 -0
- package/dist/assets.d.ts +4 -0
- package/dist/assets.js +9 -0
- package/dist/assets.json +4 -4
- package/dist/claude_marketplace.d.ts +117 -0
- package/dist/claude_marketplace.js +306 -0
- package/dist/doctor.d.ts +19 -3
- package/dist/doctor.js +32 -5
- package/dist/doctor_scopes.d.ts +34 -2
- package/dist/doctor_scopes.js +128 -20
- package/dist/setup_storage.d.ts +12 -0
- package/dist/setup_storage.js +44 -2
- package/dist/setup_writers.d.ts +25 -6
- package/dist/setup_writers.js +295 -32
- package/package.json +3 -2
package/dist/setup_writers.js
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
571
|
+
directory,
|
|
565
572
|
};
|
|
566
573
|
}
|
|
567
574
|
/**
|
|
568
|
-
* Setup's exact check for claude: our plugin, at the baked version, from
|
|
569
|
-
* effective here AND installed and enabled at the REQUESTED scope from that
|
|
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
|
|
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 &&
|
|
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:
|
|
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
|
-
|
|
601
|
-
|
|
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 (
|
|
606
|
-
return { status: "already", reason: "exact,disabled_by_user", fix: offFix };
|
|
607
|
-
// readlink BEFORE `claude plugin … --scope` writes
|
|
608
|
-
const links = symlinkCheck(ctx, cliWriteTargets("claude",
|
|
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
|
-
|
|
613
|
-
//
|
|
614
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 ${
|
|
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.
|
|
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.
|
|
36
|
+
"@consiliency/agent-board-client": "1.41.0",
|
|
36
37
|
"commander": "^14.0.1"
|
|
37
38
|
},
|
|
38
39
|
"license": "Apache-2.0",
|