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 98c4264c..24aeb525 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,53 @@ 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`"));
+ });
+
+ 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", () => {
@@ -399,7 +446,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 +504,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 +526,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 +536,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 +547,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..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
@@ -26,7 +28,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 +79,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 +98,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 +113,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..2f10dc15 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,33 @@ 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);
+// 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 ${INSTALL_HINT}`,
+ !elsewhere,
+ elsewhere ? `${labels}, not --source ${source.label}` : labels,
+ `to install from ${source.label}, ${remove}, then ${hint}`,
+ elsewhere,
);
}
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`,
),
];
}