From 01e822e3dad733be989664d9626273248e7c061d Mon Sep 17 00:00:00 2001 From: J0UH <217747670+J0UH@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:53:30 +0200 Subject: [PATCH 1/2] Installer: doctor's fixes rerun with the run's --source Every fix doctor printed was `npx -y github:chhoumann/t3-pstack install`, whatever --source the run had. Following it on a home with no marketplace added chhoumann/t3-pstack instead of the source the user named. The fixes now carry the run's --source. A GitHub --source also names that repository as the installer to run, since the installer's version is what each home converges on, and a fork's marketplace has the fork's version. The install gates now ask trustedOnly() instead of reading marketplaceCheck's ok, so they don't build a fix text they never show. --- tests/install.test.mjs | 41 ++++++++++++++++++++++++++++++++++------ tools/install.mjs | 11 ++++++----- tools/install/claude.mjs | 18 ++++++++++-------- tools/install/codex.mjs | 18 ++++++++++-------- tools/install/common.mjs | 20 +++++++++++++++----- tools/install/sheet.mjs | 8 ++++---- 6 files changed, 80 insertions(+), 36 deletions(-) diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 98c4264c..1529d392 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -20,7 +20,7 @@ import { writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { doctorClaude, installClaude, syncScopedRecords } from "../tools/install/claude.mjs"; @@ -351,6 +351,35 @@ describe("--source", () => { expect(parseSource("https://github.com/chhoumann/t3-pstack", "/home/u").key).toBe(github.key); expect(() => parseSource("./missing", cwd)).toThrow("--source ./missing: not owner/repo, a Git URL, or an existing directory"); }); + + test("is in every fix doctor prints, and a GitHub --source runs that repository's installer", () => { + const claude = fakeClaude(); + const doctor = (source) => { + const r = spawnSync(process.execPath, [join(repoRoot, "tools/install.mjs"), "doctor", "--source", source, "--json"], { + env: { HOME: tmp(), PATH: join(claude.bin, "..") }, + encoding: "utf8", + }); + return JSON.parse(r.stdout).homes.find((row) => row.harness === "claude").checks; + }; + expect(doctor("someone/t3-pstack")).toEqual([ + { + name: "installed", + ok: false, + detail: "the home does not exist", + fix: "rerun `npx -y github:someone/t3-pstack install --source someone/t3-pstack`", + }, + ]); + const local = resolve(repoRoot); + expect(doctor(local)[0].fix).toBe(`rerun \`npx -y github:chhoumann/t3-pstack install --source ${local}\``); + + const fork = parseSource("someone/t3-pstack", "/home/u"); + const home = { harness: "claude", dir: tmp(), from: ["default"], bin: "claude" }; + const fixes = doctorClaude(home, { ...context, source: fork, trusted: new Set([github.key, fork.key]) }) + .filter((c) => !c.ok) + .map((c) => c.fix); + expect(fixes).toEqual(Array(4).fill("rerun `npx -y github:someone/t3-pstack install --source someone/t3-pstack`")); + }); + }); describe("files the installer replaces", () => { @@ -399,7 +428,7 @@ describe("the model sheet", () => { expect(existsSync(join(root, ".claude-never-run"))).toBe(false); expect(moveSheet(sheet, homes)).toEqual([]); - expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers, hint: FIX })).toEqual([ { name: "machine sheet valid", ok: true, detail: "every line parses (pass --catalog to check targets)" }, { name: "home sheets", ok: true, detail: "none differ from it" }, ]); @@ -457,7 +486,7 @@ describe("the model sheet", () => { expect(moveSheet(sheet, homes)).toEqual([]); expect(existsSync(sheet)).toBe(false); - expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers, hint: FIX })).toEqual([ { name: "machine sheet", ok: false, @@ -479,7 +508,7 @@ describe("the model sheet", () => { `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, ]); expect(lstatSync(join(root, ".codex/t3-pstack-models.md")).isSymbolicLink()).toBe(false); - expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })[1]).toEqual({ + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers, hint: FIX })[1]).toEqual({ name: "home sheets", ok: false, detail: `${join(root, ".codex/t3-pstack-models.md")} differ from the machine sheet`, @@ -489,7 +518,7 @@ describe("the model sheet", () => { test("doctor warns without a sheet and fails on a malformed one", () => { const { homes, sheet } = machine(); - expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers, hint: FIX })).toEqual([ { name: "machine sheet", ok: false, @@ -500,7 +529,7 @@ describe("the model sheet", () => { ]); mkdirSync(join(sheet, ".."), { recursive: true }); writeFileSync(sheet, "bug-fix: claude-opus\nhow critics: inherit-parent\n"); - expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })[0]).toEqual({ + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers, hint: FIX })[0]).toEqual({ name: "machine sheet valid", ok: false, detail: 'bug-fix: "claude-opus" does not start with /; "how critics" is not a pstack role', diff --git a/tools/install.mjs b/tools/install.mjs index 2da40c19..09a1aec4 100755 --- a/tools/install.mjs +++ b/tools/install.mjs @@ -26,7 +26,7 @@ import { parseArgs } from "node:util"; import { doctorClaude, installClaude } from "./install/claude.mjs"; import { doctorCodex, installCodex } from "./install/codex.mjs"; -import { check, hasCli, HOME_REPO, INSTALL_HINT, parseSource, readJson } from "./install/common.mjs"; +import { check, hasCli, HOME_REPO, installHint, parseSource, readJson } from "./install/common.mjs"; import { discoverHomes } from "./install/homes.mjs"; import { doctorSheet, moveSheet, sheetPath } from "./install/sheet.mjs"; import { FROZEN_HOOKS } from "./plugin.mjs"; @@ -77,6 +77,7 @@ async function run(argv, env, home) { const facts = pluginFacts(source.local ?? fileURLToPath(new URL("..", import.meta.url))); const trusted = new Set([parseSource(HOME_REPO, home).key, source.key]); const context = { source, trusted, version: facts.version, skills: facts.skills, codexHook: facts.codexHook }; + const hint = installHint(source); const { homes, settings } = discoverHomes({ env, home, @@ -95,14 +96,14 @@ async function run(argv, env, home) { continue; } if (command === "doctor" && !existsSync(target.dir)) { - row.checks.push(check("installed", false, "the home does not exist", INSTALL_HINT)); + row.checks.push(check("installed", false, "the home does not exist", hint)); continue; } try { if (command !== "doctor") row.steps = await harness.install(target, context); row.checks.push(...(await harness.doctor(target, context))); } catch (err) { - row.checks.push(check(command === "doctor" ? "doctor" : "install", false, err.message, INSTALL_HINT)); + row.checks.push(check(command === "doctor" ? "doctor" : "install", false, err.message, hint)); } } const sheet = sheetPath(env, home); @@ -110,9 +111,9 @@ async function run(argv, env, home) { try { if (command !== "doctor") report.sheet.steps = moveSheet(sheet, homes); } catch (err) { - report.sheet.checks.push(check("move", false, err.message, INSTALL_HINT)); + report.sheet.checks.push(check("move", false, err.message, hint)); } - report.sheet.checks.push(...doctorSheet(sheet, homes, { catalog, roles: facts.roles, tiers: facts.tiers })); + report.sheet.checks.push(...doctorSheet(sheet, homes, { catalog, roles: facts.roles, tiers: facts.tiers, hint })); const all = [...report.homes.flatMap((row) => row.checks), ...report.sheet.checks]; report.failed = all.filter((c) => !c.ok && !c.warn).length; report.warnings = all.filter((c) => !c.ok && c.warn).length; diff --git a/tools/install/claude.mjs b/tools/install/claude.mjs index 52956f96..7f07edca 100644 --- a/tools/install/claude.mjs +++ b/tools/install/claude.mjs @@ -5,7 +5,7 @@ import { join } from "node:path"; import { check, claudeSource, - INSTALL_HINT, + installHint, isPlainCopy, MARKETPLACE, marketplaceCheck, @@ -13,6 +13,7 @@ import { pluginIntact, readJson, runCli, + trustedOnly, updated, writeAtomic, } from "./common.mjs"; @@ -57,7 +58,7 @@ export function installClaude(home, { source, trusted, version, skills }) { } // Nothing installs from a marketplace of another source; doctor names it. let state = read(home.dir); - if (!marketplaceCheck(state.sources, trusted).ok) return steps; + if (!trustedOnly(state.sources, trusted)) return steps; if (!state.user) { plugin("install", PLUGIN_ID, "--scope", "user"); steps.push(`installed ${read(home.dir).user?.version}`); @@ -115,7 +116,8 @@ export function syncScopedRecords(dir) { return [...steps, `backed up ${RECORDS} to ${backup}`]; } -export function doctorClaude(home, { version, skills, trusted }) { +export function doctorClaude(home, { source, trusted, version, skills }) { + const hint = installHint(source); const state = read(home.dir); const installed = state.user?.version; const whole = intact(state, skills); @@ -124,7 +126,7 @@ export function doctorClaude(home, { version, skills, trusted }) { ? readdirSync(skillsDir).filter((name) => isPlainCopy(name, join(skillsDir, name, "SKILL.md"), skills)) : []; return [ - marketplaceCheck(state.sources, trusted, `remove it with \`CLAUDE_CONFIG_DIR=${home.dir} claude plugin marketplace remove ${MARKETPLACE}\``), + marketplaceCheck(state.sources, { source, trusted }, `remove it with \`CLAUDE_CONFIG_DIR=${home.dir} claude plugin marketplace remove ${MARKETPLACE}\``), check( "installed", installed === version && whole, @@ -135,15 +137,15 @@ export function doctorClaude(home, { version, skills, trusted }) { : whole ? installed : `${installed}, but its files at ${state.user.installPath} are missing`, - INSTALL_HINT, + hint, ), - check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_HINT), - check("marketplace auto-update", autoUpdates(state.marketplace), state.marketplace ? String(autoUpdates(state.marketplace)) : "no marketplace", INSTALL_HINT), + check("enabled", state.enabled, state.enabled ? "yes" : "no", hint), + check("marketplace auto-update", autoUpdates(state.marketplace), state.marketplace ? String(autoUpdates(state.marketplace)) : "no marketplace", hint), check( "other-scope records", state.stale.length === 0, state.stale.length ? state.stale.map((r) => `${r.scope} ${r.projectPath} at ${r.version}`).join("; ") : "none stale", - INSTALL_HINT, + hint, ), check( "no plain copies of plugin skills", diff --git a/tools/install/codex.mjs b/tools/install/codex.mjs index 1ebccab5..6f0401cf 100644 --- a/tools/install/codex.mjs +++ b/tools/install/codex.mjs @@ -16,7 +16,7 @@ import { hookDigest } from "../plugin.mjs"; import { check, codexSource, - INSTALL_HINT, + installHint, isPlainCopy, launch, MARKETPLACE, @@ -25,6 +25,7 @@ import { PLUGIN_ID, pluginIntact, runCli, + trustedOnly, updated, } from "./common.mjs"; @@ -202,7 +203,7 @@ export async function installCodex(home, { source, trusted, version, skills, cod before = await state(); } // Nothing installs from a marketplace of another source; doctor names it. - if (!marketplaceCheck(before.sources, trusted).ok) return steps; + if (!trustedOnly(before.sources, trusted)) return steps; const step = codexStep(before, version); if (step) { // A local marketplace is read in place; a Git one is a snapshot to refresh. @@ -231,7 +232,7 @@ export async function installCodex(home, { source, trusted, version, skills, cod // entry from any other hooks file stays untrusted, and doctor names it. async function trustHooks(server, trusted, pin) { const { config, layers } = await server.request("config/read", { includeLayers: true }); - if (!marketplaceCheck(marketplaceSources(config), trusted).ok) return []; + if (!trustedOnly(marketplaceSources(config), trusted)) return []; const user = layers.find((layer) => layer.name.type === "user"); const pending = (await pluginHooks(server)).filter( (h) => (h.trustStatus === "untrusted" || h.trustStatus === "modified") && isPinned(h, pin), @@ -255,7 +256,8 @@ async function trustHooks(server, trusted, pin) { return pending.map((h) => `trusted hook ${h.key} (was ${h.trustStatus})`); } -export async function doctorCodex(home, { version, skills, trusted, codexHook }) { +export async function doctorCodex(home, { source, trusted, version, skills, codexHook }) { + const hint = installHint(source); return withServer(home, async (server) => { const state = await pluginState(server, home.dir, skills); const hooks = await pluginHooks(server); @@ -264,7 +266,7 @@ export async function doctorCodex(home, { version, skills, trusted, codexHook }) const installed = state.versions.join(", "); const good = (h) => h.trustStatus === "trusted" && isPinned(h, codexHook); return [ - marketplaceCheck(state.sources, trusted, `remove it with \`CODEX_HOME=${home.dir} codex plugin marketplace remove ${MARKETPLACE}\``), + marketplaceCheck(state.sources, { source, trusted }, `remove it with \`CODEX_HOME=${home.dir} codex plugin marketplace remove ${MARKETPLACE}\``), check( "installed", state.installed && installed === version && state.intact, @@ -275,16 +277,16 @@ export async function doctorCodex(home, { version, skills, trusted, codexHook }) : state.intact ? installed : `its files in ${join(cacheDir(home.dir), installed)} are missing`, - INSTALL_HINT, + hint, ), - check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_HINT), + check("enabled", state.enabled, state.enabled ? "yes" : "no", hint), check( "session-start hook trusted", hooks.length > 0 && hooks.every(good), hooks.length ? hooks.map((h) => `${h.key} ${h.trustStatus}${isPinned(h, codexHook) ? "" : `, not from the ${codexHook.path} this release pins`}`).join("; ") : "no t3-pstack hook listed", - INSTALL_HINT, + hint, ), check( "no plain copies of plugin skills", diff --git a/tools/install/common.mjs b/tools/install/common.mjs index f78de9f9..8015f908 100644 --- a/tools/install/common.mjs +++ b/tools/install/common.mjs @@ -10,7 +10,6 @@ import { basename, delimiter, dirname, extname, join, resolve } from "node:path" export const PLUGIN_ID = "t3-pstack@t3-pstack"; export const MARKETPLACE = "t3-pstack"; export const HOME_REPO = "chhoumann/t3-pstack"; -export const INSTALL_HINT = "rerun `npx -y github:chhoumann/t3-pstack install`"; // `~`, `~/` and `~\` expand as T3 expands them (provider-core // pathExpansion.ts). `~user` does not. @@ -29,7 +28,7 @@ export function readJson(path) { // source is written: a GitHub repository by its lowercased `owner/repo`, a // directory by its real path. const GITHUB_URL = /^(?:https?:\/\/|ssh:\/\/git@|git@)github\.com[/:]([^/]+\/[^/]+?)(?:\.git)?\/?$/i; -const github = (repo) => ({ key: `github:${repo.toLowerCase()}`, label: repo }); +const github = (repo) => ({ key: `github:${repo.toLowerCase()}`, label: repo, repo }); const gitUrl = (url) => (GITHUB_URL.test(url) ? { ...github(url.match(GITHUB_URL)[1]), label: url } : { key: `git:${url}`, label: url }); const directory = (path) => ({ key: `local:${existsSync(path) ? realpathSync(path) : resolve(path)}`, label: path }); @@ -43,6 +42,13 @@ export function parseSource(text, home) { return { ...directory(path), arg: path, local: path }; } +// The command that reruns this install from `source`. A GitHub source is a +// copy of this repository, so its own installer converges on its version. +export function installHint(source) { + if (source.key === github(HOME_REPO).key) return "rerun `npx -y github:chhoumann/t3-pstack install`"; + return `rerun \`npx -y github:${source.repo ?? HOME_REPO} install --source ${source.arg}\``; +} + // The sources a Claude Code home or a Codex home records for a marketplace. // A kind the installer never adds keys to itself, so it never matches. export function claudeSource(source) { @@ -58,17 +64,21 @@ export function codexSource(entry) { return { key: JSON.stringify(entry), label: JSON.stringify(entry) }; } +// A home installs only from a marketplace whose every source is trusted. +export const trustedOnly = (sources, trusted) => sources.length > 0 && sources.every((s) => trusted.has(s.key)); + // The marketplace check both doctors run. A marketplace named t3-pstack from // any source but the trusted ones is not installed from, nor its hook trusted. -export function marketplaceCheck(sources, trusted, remove) { - if (!sources.length) return check("marketplace", false, "none", INSTALL_HINT); +export function marketplaceCheck(sources, { source, trusted }, remove) { + const hint = installHint(source); + if (!sources.length) return check("marketplace", false, "none", hint); const labels = [...new Set(sources.map((s) => s.label))].join(", "); const foreign = sources.filter((s) => !trusted.has(s.key)); return check( "marketplace", foreign.length === 0, foreign.length ? `${labels}, which is neither ${HOME_REPO} nor --source` : labels, - `if you trust it, rerun with --source ${foreign[0]?.label}; otherwise ${remove}, then ${INSTALL_HINT}`, + `if you trust it, rerun with --source ${foreign[0]?.label}; otherwise ${remove}, then ${hint}`, ); } diff --git a/tools/install/sheet.mjs b/tools/install/sheet.mjs index 94990f9f..662395d6 100644 --- a/tools/install/sheet.mjs +++ b/tools/install/sheet.mjs @@ -7,7 +7,7 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSyn import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { checkSheet, parseSheet } from "../../plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs"; -import { check, INSTALL_HINT, tempBeside, writeAtomic } from "./common.mjs"; +import { check, tempBeside, writeAtomic } from "./common.mjs"; export const HOME_SHEET = "t3-pstack-models.md"; const SETUP_HINT = "run /t3-pstack:setup-pstack in a T3 thread"; @@ -63,7 +63,7 @@ export function moveSheet(sheet, homes) { return steps; } -export function doctorSheet(sheet, homes, { catalog, roles, tiers }) { +export function doctorSheet(sheet, homes, { catalog, roles, tiers, hint }) { const old = homeSheets(homes).filter(isFile); if (!isFile(sheet)) { const differ = new Set(old.map((path) => readFileSync(path, "utf8"))).size > 1; @@ -73,7 +73,7 @@ export function doctorSheet(sheet, homes, { catalog, roles, tiers }) { "machine sheet", false, `missing; ${old.join(", ")} ${differ ? "differ, so the installer leaves them" : "not moved yet"}`, - differ ? `keep one, then ${INSTALL_HINT}` : INSTALL_HINT, + differ ? `keep one, then ${hint}` : hint, ), ]; } @@ -96,7 +96,7 @@ export function doctorSheet(sheet, homes, { catalog, roles, tiers }) { "home sheets", shadowing.length === 0, shadowing.length ? `${shadowing.join(", ")} differ from the machine sheet` : "none differ from it", - `move each aside, then ${INSTALL_HINT} to link it`, + `move each aside, then ${hint} to link it`, ), ]; } From 9e535de2f066cd6fb8ee825e1db5a001b044cb24 Mon Sep 17 00:00:00 2001 From: J0UH <217747670+J0UH@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:53:37 +0200 Subject: [PATCH 2/2] Installer: warn when --source does not switch a home The installer trusts chhoumann/t3-pstack and the run's --source, and it adds a marketplace only to a home that has none. So a home already on chhoumann/t3-pstack stayed there under `install --source owner/fork`, while the report's first line said "from owner/fork" and every check passed. Switching the home in place would also move the default homes to a local checkout on a test run with `--source ./t3-pstack`, and the next plain `update` would then refuse them. So the run still leaves the home's source alone, and the marketplace check warns that the home is not on --source, with the remove command and the rerun that move it. --- README.md | 2 +- tests/install.test.mjs | 18 ++++++++++++++++++ tools/install.mjs | 4 +++- tools/install/common.mjs | 18 +++++++++++++++--- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0073f77e..c4dca9bb 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ npx -y github:chhoumann/t3-pstack doctor `npx -y github:chhoumann/t3-pstack` runs the installer from the latest commit on `main`. That is the rolling channel, the same one each home's marketplace auto-updates from. To run one release's installer, name its tag, as in `npx -y github:chhoumann/t3-pstack#v2.0.0-rc.15 doctor`. -`install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home ` or `--codex-home `. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source ` on the same run, and a home whose marketplace has another source is left as it is. A local directory needs a path such as `./t3-pstack`, since `owner/repo` always means GitHub. On Codex it also trusts t3-pstack's session-start hook, once its hooks file is the one this release pins. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release. +`install` and `update` are the same command, and a second run changes nothing. With Node 20 or later, it installs or updates the `t3-pstack@t3-pstack` plugin in every Claude Code and Codex config home T3 can run: `~/.claude` and `~/.codex`, `$CLAUDE_CONFIG_DIR` and `$CODEX_HOME`, and the home of each `claudeAgent` and `codex` instance in T3's settings, disabled ones included. Name any other home with `--claude-home ` or `--codex-home `. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source ` on the same run, and a home whose marketplace has another source is left as it is. A run never switches a home's source, so a home already on `chhoumann/t3-pstack` stays on it under another `--source`, and `doctor` warns and prints the commands that move it. A local directory needs a path such as `./t3-pstack`, since `owner/repo` always means GitHub. On Codex it also trusts t3-pstack's session-start hook, once its hooks file is the one this release pins. Codex runs a plugin's hook only once it is trusted, and T3's Codex sessions never ask, so without this step the hook never runs. Run `update` after each release. `doctor` reports on the same homes and changes nothing itself: each marketplace's source, the installed version, whether its files are all there, whether it is enabled, the Codex hook's trust, Claude Code records that pin an old version, plain copies of the plugin's skills, and the model sheet. It exits 1 on any problem and prints a fix for each. diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 1529d392..24aeb525 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -380,6 +380,24 @@ describe("--source", () => { expect(fixes).toEqual(Array(4).fill("rerun `npx -y github:someone/t3-pstack install --source someone/t3-pstack`")); }); + test("leaves a home on chhoumann/t3-pstack where it is, and doctor warns with the commands that switch it", () => { + const { dir } = claudeHome(); + syncScopedRecords(dir); + const fork = parseSource("someone/t3-pstack", "/home/u"); + const forked = { ...context, source: fork, trusted: new Set([github.key, fork.key]) }; + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(installClaude(home, forked)).toEqual(["turned on marketplace auto-update"]); + expect(doctorClaude(home, forked)[0]).toEqual({ + name: "marketplace", + ok: false, + warn: true, + detail: "chhoumann/t3-pstack, not --source someone/t3-pstack", + fix: + `to install from someone/t3-pstack, remove it with \`CLAUDE_CONFIG_DIR=${dir} claude plugin marketplace remove t3-pstack\`, ` + + "then rerun `npx -y github:someone/t3-pstack install --source someone/t3-pstack`", + }); + }); }); describe("files the installer replaces", () => { diff --git a/tools/install.mjs b/tools/install.mjs index 09a1aec4..fdef3bdb 100755 --- a/tools/install.mjs +++ b/tools/install.mjs @@ -10,7 +10,9 @@ // Each home's t3-pstack marketplace must come from chhoumann/t3-pstack or from // this run's --source. A home whose marketplace has another source, such as a // local --source from an earlier run, gets nothing installed and no hook -// trusted, and doctor fails it with that source. +// trusted, and doctor fails it with that source. A run never switches a +// home's source: one from chhoumann/t3-pstack stays on it under another +// --source, and doctor warns with the commands that switch it. // // npx -y github:chhoumann/t3-pstack // --source marketplace source, default chhoumann/t3-pstack diff --git a/tools/install/common.mjs b/tools/install/common.mjs index 8015f908..2f10dc15 100644 --- a/tools/install/common.mjs +++ b/tools/install/common.mjs @@ -69,16 +69,28 @@ export const trustedOnly = (sources, trusted) => sources.length > 0 && sources.e // The marketplace check both doctors run. A marketplace named t3-pstack from // any source but the trusted ones is not installed from, nor its hook trusted. +// One from chhoumann/t3-pstack, on a run whose --source names another, is +// still installed from, and the check warns that the run did not switch it. export function marketplaceCheck(sources, { source, trusted }, remove) { const hint = installHint(source); if (!sources.length) return check("marketplace", false, "none", hint); const labels = [...new Set(sources.map((s) => s.label))].join(", "); const foreign = sources.filter((s) => !trusted.has(s.key)); + if (foreign.length) { + return check( + "marketplace", + false, + `${labels}, which is neither ${HOME_REPO} nor --source`, + `if you trust it, rerun with --source ${foreign[0].label}; otherwise ${remove}, then ${hint}`, + ); + } + const elsewhere = !sources.some((s) => s.key === source.key); return check( "marketplace", - foreign.length === 0, - foreign.length ? `${labels}, which is neither ${HOME_REPO} nor --source` : labels, - `if you trust it, rerun with --source ${foreign[0]?.label}; otherwise ${remove}, then ${hint}`, + !elsewhere, + elsewhere ? `${labels}, not --source ${source.label}` : labels, + `to install from ${source.label}, ${remove}, then ${hint}`, + elsewhere, ); }