Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir>` or `--codex-home <dir>`. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source <owner/repo|git URL|dir>` 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 <dir>` or `--codex-home <dir>`. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack`, or from `--source <owner/repo|git URL|dir>` 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.

Expand Down
59 changes: 53 additions & 6 deletions tests/install.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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" },
]);
Expand Down Expand Up @@ -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,
Expand All @@ -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`,
Expand All @@ -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,
Expand All @@ -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 <providerInstanceId>/<modelId>; "how critics" is not a pstack role',
Expand Down
15 changes: 9 additions & 6 deletions tools/install.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <install|update|doctor>
// --source <owner/repo|git URL|dir> marketplace source, default chhoumann/t3-pstack
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -95,24 +98,24 @@ 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);
report.sheet = { path: sheet, steps: [], checks: [] };
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;
Expand Down
18 changes: 10 additions & 8 deletions tools/install/claude.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ import { join } from "node:path";
import {
check,
claudeSource,
INSTALL_HINT,
installHint,
isPlainCopy,
MARKETPLACE,
marketplaceCheck,
PLUGIN_ID,
pluginIntact,
readJson,
runCli,
trustedOnly,
updated,
writeAtomic,
} from "./common.mjs";
Expand Down Expand Up @@ -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}`);
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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",
Expand Down
18 changes: 10 additions & 8 deletions tools/install/codex.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { hookDigest } from "../plugin.mjs";
import {
check,
codexSource,
INSTALL_HINT,
installHint,
isPlainCopy,
launch,
MARKETPLACE,
Expand All @@ -25,6 +25,7 @@ import {
PLUGIN_ID,
pluginIntact,
runCli,
trustedOnly,
updated,
} from "./common.mjs";

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -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",
Expand Down
Loading