diff --git a/CONTEXT.md b/CONTEXT.md index 077ea88..23969b1 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -6,7 +6,7 @@ Names for the concepts this repo's design discussions keep reaching for. Archite - **T3 form** - upstream's skill text with every Cursor mechanism rewritten into what T3 provides: `delegate_task` children and persona briefs read by path, `schedule_task`, `watch_pull_request`, `t3_thread_*` reads, and sheet entries for models. It lives in the text itself, so a skill needs no second file to run on T3. - **Agent Skill** - one directory under `plugins/pstack/skills/` with portable `name` and `description` frontmatter. Upstream marks every pstack and thermos skill but `setup-pstack` `disable-model-invocation: true`, and the six cursor-team-kit skills carry no flag. The port drops the flag from `poteto-mode` alone, so the session-start hook can invoke it, and `poteto-mode` opens the others by path. - **Model sheet** - `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, one role per line, each value a T3 entry (`/` plus `=` pairs) or `inherit-parent`. `setup-pstack` writes it and `t3-sheet.mjs` checks it against `orchestrator_capabilities`. -- **Installer** - `tools/install.mjs`, the package's `t3-pstack` bin, run as `npx -y github:chhoumann/t3-pstack `. It installs the plugin in every Claude Code and Codex home T3 can run, trusts the Codex session-start hook, moves the model sheet to its machine path, and checks the result. +- **Installer** - `tools/install.mjs`, the package's `t3-pstack` bin, run as `npx -y github:chhoumann/t3-pstack `. It installs the plugin in every Claude Code and Codex home T3 can run, trusts the Codex session-start hook, moves the model sheet to its machine path, and checks the result. With `--project ` or `--local ` it installs for one project: at that Claude Code scope, and through the project's `.codex/config.toml`, a layer Codex reads only for a project the home trusts. - **Frozen hooks file** - `hooks/hooks.json` or `hooks/codex-hooks.json`, pinned in `tools/frozen-hooks.json`. Codex trusts a hook by a hash of its entry, so a change stops the hook in every Codex home until the installer reruns. What the hook says lives in `session-start.sh` and the routing text, which change freely. - **Model policy** - the role defaults in `plugins/pstack/models.json`, as tiers of T3 entries. The generator stamps them into each skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block. - **Role** - a named unit of delegated work with its own model choice (`arena runners`, `how explorer`, `swarm workers`). The role vocabulary lives in `models.json`. diff --git a/README.md b/README.md index c78ab41..642c542 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ npx -y github:chhoumann/t3-pstack 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. +To install for one project instead of every session, add `--project ` or `--local ` to `install`, `update`, or `doctor`. On Claude Code, `--project` installs at project scope, writing the marketplace and the enabled plugin into the project's `.claude/settings.json` so the team shares them, and `--local` writes them into `.claude/settings.local.json` for you alone. Codex has no plugin scopes, so both flags enable the plugin in the project's `.codex/config.toml` and mark the project trusted in each Codex home, since Codex ignores a project's config until it is trusted. The plugin's files and the hook's trust stay in the home, and a home that did not already have the plugin enabled keeps it disabled outside the projects that enable it. Each teammate runs the same command once, since every home needs its own copy of the plugin. + `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. Then run `/t3-pstack:setup-pstack` in a T3 thread on each machine. It writes the model sheet, `~/.config/t3-pstack/models.md`, with one T3 target per role (`/` plus that provider's options), and checks it against the machine's `orchestrator_capabilities`. Without a sheet, every role runs on the defaults in its skill's Models section. diff --git a/tests/ci-forks-step.test.mjs b/tests/ci-forks-step.test.mjs index d58c5cb..dcc1411 100644 --- a/tests/ci-forks-step.test.mjs +++ b/tests/ci-forks-step.test.mjs @@ -18,7 +18,7 @@ function runStep(upstream, failing = "") { if (upstream !== null) writeFileSync(join(dir, "tools/upstream.json"), upstream); writeFileSync(calls, ""); writeFileSync(join(dir, "step.sh"), step.run); - writeFileSync(join(dir, "bin/bun"), '#!/bin/bash\necho "$2 $3" >> "$CALLS"\n[ "$2" != "$FAILING" ]\n', { mode: 0o755 }); + writeFileSync(join(dir, "bin/bun"), '#!/usr/bin/env bash\necho "$2 $3" >> "$CALLS"\n[ "$2" != "$FAILING" ]\n', { mode: 0o755 }); const { status, stdout } = spawnSync("bash", ["-e", "step.sh"], { cwd: dir, env: { PATH: `${join(dir, "bin")}:${process.env.PATH}`, RUNNER_TEMP: dir, CALLS: calls, FAILING: failing }, diff --git a/tests/install-e2e.test.mjs b/tests/install-e2e.test.mjs index dc1a5d0..7122024 100644 --- a/tests/install-e2e.test.mjs +++ b/tests/install-e2e.test.mjs @@ -43,9 +43,9 @@ function sh(bin, args, env = {}) { return r.stdout; } -// `source` null runs with no --source, as a user does. -function installer(command, source = repoRoot) { - const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, ...(source ? ["--source", source] : []), "--json"], { +// `source` null runs with no --source, as a user does. `extra` adds flags. +function installer(command, source = repoRoot, extra = []) { + const r = spawnSync("node", [join(repoRoot, "tools/install.mjs"), command, ...(source ? ["--source", source] : []), ...extra, "--json"], { cwd: root, env: process.env, encoding: "utf8", @@ -75,10 +75,10 @@ async function until(done, ms) { // A T3-shaped Codex session: thread/start, then one turn. SessionStart runs // at the first turn, before the model call. -async function probe(codexHome) { +async function probe(codexHome, cwd = root) { const server = await AppServer.start({ dir: codexHome, bin: "codex" }); try { - const { thread } = await server.request("thread/start", { cwd: root, approvalPolicy: "never", sandbox: "read-only" }); + const { thread } = await server.request("thread/start", { cwd, approvalPolicy: "never", sandbox: "read-only" }); await server.request("turn/start", { threadId: thread.id, input: [{ type: "text", text: "ping" }] }); expect(await until(() => server.notifications.some((m) => m.method === "turn/completed"), 90_000), "turn completed").toBe(true); const rollout = readFileSync(thread.path, "utf8") @@ -484,3 +484,116 @@ describe.skipIf(!enabled)("the installer, on a Codex home that changes while it expect(await trustState()).toBe(null); }, 300_000); }); + +describe.skipIf(!enabled)("the installer, for one project", () => { + isolate({}); + let project; + let elsewhere; + const source = () => repoRoot.replace(/\/$/, ""); + + test("install: the plugin loads in the project's sessions and nowhere else", async () => { + project = join(root, "project"); + elsewhere = join(root, "elsewhere"); + for (const dir of [project, elsewhere]) mkdirSync(dir, { recursive: true }); + sh("git", ["init", "-q", project]); + + const { status, report } = installer("install", repoRoot, ["--project", project]); + console.log("project install:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir, steps })))); + expect(failing(report.homes.flatMap((h) => h.checks))).toEqual([]); + expect(status).toBe(0); + expect(report.project).toEqual({ scope: "project", dir: project }); + const claudeSettings = join(project, ".claude/settings.json"); + expect(row(report, ".claude").steps).toEqual([ + `added the t3-pstack marketplace from ${source()} to ${claudeSettings}`, + `installed ${version} at project scope`, + ]); + expect(row(report, ".codex").steps).toEqual([ + `added the t3-pstack marketplace from ${source()}`, + `installed ${version}`, + "left it disabled outside the projects that enable it", + `trusted ${project} in ${join(home, ".codex/config.toml")}, so Codex reads its .codex/config.toml`, + `enabled it in ${join(project, ".codex/config.toml")}`, + `trusted hook ${HOOK} (was untrusted)`, + ]); + expect(JSON.parse(readFileSync(claudeSettings, "utf8")).enabledPlugins).toEqual({ "t3-pstack@t3-pstack": true }); + expect(existsSync(join(home, ".claude/settings.json"))).toBe(false); + + const inside = await probe(join(home, ".codex"), project); + expect(inside.hookEvents).toEqual(["hook/started", "hook/completed"]); + expect(inside.routedMessages).toBe(1); + const outside = await probe(join(home, ".codex"), elsewhere); + expect(outside.hookEvents).toEqual([]); + expect(outside.routedMessages).toBe(0); + }, 300_000); + + test("a second install changes nothing, and the global doctor sees no global install", () => { + const before = snapshot(); + const { status, report } = installer("update", repoRoot, ["--project", project]); + expect(status).toBe(0); + expect(report.homes.flatMap((h) => h.steps)).toEqual([]); + expect(snapshot()).toEqual(before); + expect(installer("doctor", repoRoot, ["--project", project]).status).toBe(0); + const global = installer("doctor").report; + // Outside the project the plugin is off, so Codex lists none of its hooks. + expect(failing(row(global, ".codex").checks)).toEqual(["enabled: no", "session-start hook trusted: no t3-pstack hook listed"]); + }, 300_000); + + test("--local writes the project's local settings, and keeps the Codex config's other lines", () => { + const local = join(root, "local"); + mkdirSync(join(local, ".codex"), { recursive: true }); + writeFileSync(join(local, ".codex/config.toml"), 'model = "o3"\n'); + const { status, report } = installer("install", repoRoot, ["--local", local]); + expect(failing(report.homes.flatMap((h) => h.checks))).toEqual([]); + expect(status).toBe(0); + expect(existsSync(join(local, ".claude/settings.json"))).toBe(false); + expect(JSON.parse(readFileSync(join(local, ".claude/settings.local.json"), "utf8")).enabledPlugins).toEqual({ + "t3-pstack@t3-pstack": true, + }); + expect(readFileSync(join(local, ".codex/config.toml"), "utf8")).toBe('model = "o3"\n\n[plugins."t3-pstack@t3-pstack"]\nenabled = true\n'); + }, 300_000); + + test("trusts neither the project nor a hook when the project's own config names another marketplace", () => { + const counterfeit = pluginCopy(join(root, "counterfeit")); + const other = join(root, "other"); + mkdirSync(join(other, ".codex"), { recursive: true }); + writeFileSync(join(other, ".codex/config.toml"), `[marketplaces.t3-pstack]\nsource_type = "local"\nsource = "${counterfeit}"\n`); + const { status, report } = installer("install", repoRoot, ["--project", other]); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(failing(row(report, ".codex").checks)[0]).toBe( + `marketplace: ${source()}, ${counterfeit}, which is neither chhoumann/t3-pstack nor --source`, + ); + }, 300_000); + + test("registers the marketplace in every fresh Claude Code home, at either scope", () => { + for (const scope of ["project", "local"]) { + const shared = join(root, `shared-${scope}`); + mkdirSync(shared, { recursive: true }); + const homes = [`claude-a-${scope}`, `claude-b-${scope}`]; + const { status, report } = installer("install", repoRoot, [`--${scope}`, shared, ...homes.flatMap((h) => ["--claude-home", join(root, h)])]); + for (const h of homes) expect(failing(row(report, `../${h}`).checks)).toEqual([]); + expect(status).toBe(0); + } + }, 300_000); + + test("adds the home's own marketplace when only the project's config names it", () => { + const codexHome = join(root, "codex-fresh"); + const named = join(root, "named"); + mkdirSync(join(named, ".codex"), { recursive: true }); + writeFileSync(join(named, ".codex/config.toml"), `[marketplaces.t3-pstack]\nsource_type = "local"\nsource = "${source()}"\n`); + const { status, report } = installer("install", repoRoot, ["--project", named, "--codex-home", codexHome]); + expect(failing(row(report, "../codex-fresh").checks)).toEqual([]); + expect(status).toBe(0); + expect(row(report, "../codex-fresh").steps.slice(0, 2)).toEqual([`added the t3-pstack marketplace from ${source()}`, `installed ${version}`]); + }, 300_000); + + test("a later global install enables the plugin everywhere, and a project install keeps it so", () => { + expect(installer("install").status).toBe(0); + const next = join(root, "next"); + mkdirSync(next, { recursive: true }); + const { status, report } = installer("install", repoRoot, ["--project", next]); + expect(status).toBe(0); + expect(row(report, ".codex").steps).not.toContain("left it disabled outside the projects that enable it"); + expect(installer("doctor").status).toBe(0); + }, 300_000); +}); diff --git a/tests/install.test.mjs b/tests/install.test.mjs index 98c4264..b9214bc 100644 --- a/tests/install.test.mjs +++ b/tests/install.test.mjs @@ -24,7 +24,7 @@ import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { doctorClaude, installClaude, syncScopedRecords } from "../tools/install/claude.mjs"; -import { AppServer } from "../tools/install/codex.mjs"; +import { AppServer, enableInProjectConfig } from "../tools/install/codex.mjs"; import { parseSource, writeAtomic } from "../tools/install/common.mjs"; import { discoverHomes } from "../tools/install/homes.mjs"; import { doctorSheet, moveSheet } from "../tools/install/sheet.mjs"; @@ -54,11 +54,12 @@ const skills = new Map(["how", "why"].map((name) => [name, SKILL(name)])); const github = parseSource("chhoumann/t3-pstack", "/home/u"); const context = { source: github, trusted: new Set([github.key]), version: "2.0.0-rc.3", skills }; -// A `claude` that records each call and changes nothing. -function fakeClaude() { +// A `claude` that records each call, and the directory it ran from when +// `where` is set, and changes nothing. +function fakeClaude({ where = false } = {}) { const dir = tmp(); const bin = join(dir, "claude"); - writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${dir}/calls"\n`); + writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' "$*${where ? " in $PWD" : ""}" >> "${dir}/calls"\n`); chmodSync(bin, 0o755); return { bin, calls: () => (existsSync(join(dir, "calls")) ? readFileSync(join(dir, "calls"), "utf8").trim().split("\n") : []) }; } @@ -296,6 +297,159 @@ describe("a stale other-scope Claude Code record", () => { }); }); +describe("a Claude Code project install", () => { + const FIX_FOR = (project) => `rerun \`npx -y github:chhoumann/t3-pstack install --${project.scope} ${project.dir}\``; + + // A home that already knows the marketplace, and a project whose record + // and settings are there when `installed` is set. + function projectHome(scope, { installed = false } = {}) { + const { dir, user } = claudeHome({ autoUpdate: true }); + const project = { scope, dir: tmp() }; + const settingsPath = join(project.dir, ".claude", scope === "local" ? "settings.local.json" : "settings.json"); + const records = JSON.parse(readFileSync(join(dir, "plugins/installed_plugins.json"), "utf8")); + records.plugins["t3-pstack@t3-pstack"] = installed ? [{ ...user, scope, projectPath: project.dir }] : []; + writeJson(join(dir, "plugins/installed_plugins.json"), records); + if (installed) { + writeJson(settingsPath, { + extraKnownMarketplaces: { "t3-pstack": { source: { source: "github", repo: "chhoumann/t3-pstack" }, autoUpdate: true } }, + enabledPlugins: { "t3-pstack@t3-pstack": true }, + }); + } + return { home: { harness: "claude", dir, from: ["default"], bin: "claude" }, project, settingsPath, user }; + } + + test("runs every command at the project's scope, from the project, and turns on auto-update in its settings", () => { + const { home, project, settingsPath } = projectHome("project"); + const settings = JSON.parse(readFileSync(join(home.dir, "settings.json"), "utf8")); + delete settings.extraKnownMarketplaces["t3-pstack"].autoUpdate; + writeJson(join(home.dir, "settings.json"), settings); + const claude = fakeClaude({ where: true }); + expect(installClaude({ ...home, bin: claude.bin }, { ...context, project })).toEqual([ + `added the t3-pstack marketplace from chhoumann/t3-pstack to ${settingsPath}`, + "installed undefined at project scope", + `enabled it in ${settingsPath}`, + `turned on marketplace auto-update in ${settingsPath}`, + ]); + expect(claude.calls()).toEqual([ + `plugin marketplace add --scope project chhoumann/t3-pstack in ${project.dir}`, + `plugin install t3-pstack@t3-pstack --scope project in ${project.dir}`, + `plugin enable t3-pstack@t3-pstack --scope project in ${project.dir}`, + ]); + expect(JSON.parse(readFileSync(settingsPath, "utf8")).extraKnownMarketplaces["t3-pstack"]).toEqual({ + source: { source: "github", repo: "chhoumann/t3-pstack" }, + autoUpdate: true, + }); + // The home's own settings and its other records are the global install's. + expect(JSON.parse(readFileSync(join(home.dir, "settings.json"), "utf8"))).toEqual(settings); + }); + + test("doctor reads the project's record and settings, at either scope, and names the project's own fix", () => { + for (const scope of ["project", "local"]) { + const { home, project, settingsPath, user } = projectHome(scope, { installed: true }); + expect(doctorClaude(home, { ...context, project })).toEqual([ + { name: "marketplace", ok: true, detail: "chhoumann/t3-pstack" }, + { name: "installed", ok: true, detail: user.version }, + { name: "enabled", ok: true, detail: `yes, in ${settingsPath}` }, + { name: "marketplace auto-update", ok: true, detail: "true" }, + { name: "no plain copies of plugin skills", ok: true, detail: "none" }, + ]); + const other = { scope, dir: tmp() }; + expect(doctorClaude(home, { ...context, project: other }).filter((c) => !c.ok)).toEqual([ + { name: "installed", ok: false, detail: `not installed at ${scope} scope for ${other.dir}`, fix: FIX_FOR(other) }, + { + name: "enabled", + ok: false, + detail: `no, in ${join(other.dir, ".claude", scope === "local" ? "settings.local.json" : "settings.json")}`, + fix: FIX_FOR(other), + }, + ]); + } + }); + + test("doctor fails a project install that the project's local settings turn off", () => { + const { home, project } = projectHome("project", { installed: true }); + const local = join(project.dir, ".claude/settings.local.json"); + writeJson(local, { enabledPlugins: { "t3-pstack@t3-pstack": false } }); + expect(doctorClaude(home, { ...context, project }).filter((c) => !c.ok)).toEqual([ + { name: "enabled", ok: false, detail: `${local} turns it off`, fix: `remove that entry, or ${FIX_FOR({ ...project, scope: "local" })}` }, + ]); + }); + + test("installs nothing beside another source that the project's other settings file declares", () => { + for (const [scope, otherFile] of [["project", "settings.local.json"], ["local", "settings.json"]]) { + const { home, project } = projectHome(scope); + const elsewhere = tmp(); + writeJson(join(project.dir, ".claude", otherFile), { + extraKnownMarketplaces: { "t3-pstack": { source: { source: "directory", path: elsewhere } } }, + }); + const claude = fakeClaude(); + expect(installClaude({ ...home, bin: claude.bin }, { ...context, project })).toEqual([]); + expect(claude.calls()).toEqual([]); + expect(doctorClaude(home, { ...context, project })[0]).toMatchObject({ + ok: false, + detail: `chhoumann/t3-pstack, ${elsewhere}, which is neither chhoumann/t3-pstack nor --source`, + }); + } + }); + + test("doctor flags a plain copy of a plugin skill in the project", () => { + const { home, project } = projectHome("project", { installed: true }); + mkdirSync(join(project.dir, ".claude/skills/why"), { recursive: true }); + writeFileSync(join(project.dir, ".claude/skills/why/SKILL.md"), SKILL("why")); + expect(doctorClaude(home, { ...context, project }).filter((c) => !c.ok)).toEqual([ + { + name: "no plain copies of plugin skills", + ok: false, + detail: join(project.dir, ".claude/skills/why"), + fix: "delete these folders; the plugin ships the same skills", + }, + ]); + }); +}); + +describe("a Codex project's config.toml", () => { + test("gains the plugin's table, and keeps every other line", () => { + const path = join(tmp(), ".codex/config.toml"); + enableInProjectConfig(path); + expect(readFileSync(path, "utf8")).toBe('[plugins."t3-pstack@t3-pstack"]\nenabled = true\n'); + writeFileSync(path, 'model = "o3"'); + enableInProjectConfig(path); + expect(readFileSync(path, "utf8")).toBe('model = "o3"\n\n[plugins."t3-pstack@t3-pstack"]\nenabled = true\n'); + }); + + test("leaves a file that already names the plugin, or has a plugins value, to the user", () => { + const path = join(tmp(), "config.toml"); + for (const text of ['[plugins."t3-pstack@t3-pstack"]\n"enabled" = false\n', 'notes = """\n[plugins."t3-pstack@t3-pstack"]\n"""\n']) { + writeFileSync(path, text); + expect(() => enableInProjectConfig(path)).toThrow(`${path} already has a plugins entry`); + expect(readFileSync(path, "utf8")).toBe(text); + } + // An inline `plugins = {}` cannot take a table after it. + writeFileSync(path, "plugins = {}\n"); + expect(() => enableInProjectConfig(path, { plugins: {} })).toThrow(`${path} already has a plugins entry`); + expect(readFileSync(path, "utf8")).toBe("plugins = {}\n"); + }); +}); + +describe("--project and --local", () => { + const cli = (...args) => + spawnSync(process.execPath, [join(repoRoot, "tools/install.mjs"), "doctor", ...args], { env: { HOME: tmp(), PATH: tmp() }, encoding: "utf8" }); + + test("name one existing directory between them", () => { + const dir = tmp(); + expect(cli("--project", dir, "--local", dir)).toMatchObject({ status: 2, stderr: "t3-pstack: --project and --local name one project between them\n" }); + expect(cli("--local", join(dir, "missing"))).toMatchObject({ status: 2, stderr: `t3-pstack: --local ${join(dir, "missing")}: not a directory\n` }); + }); + + test("report the project by its real path and scope", () => { + const dir = tmp(); + symlinkSync(dir, join(dir, "..", `${dir.split("/").pop()}-link`)); + scratch.push(`${dir}-link`); + const r = cli("--local", `${dir}-link`, "--json"); + expect(JSON.parse(r.stdout).project).toEqual({ scope: "local", dir }); + }); +}); + describe("a t3-pstack marketplace from another source", () => { function counterfeit() { const { dir } = claudeHome({ autoUpdate: true }); diff --git a/tools/install.mjs b/tools/install.mjs index 2da40c1..2b023fd 100755 --- a/tools/install.mjs +++ b/tools/install.mjs @@ -14,19 +14,20 @@ // // npx -y github:chhoumann/t3-pstack // --source marketplace source, default chhoumann/t3-pstack +// --project , --local install for one project, at project or local scope // --claude-home , --codex-home more homes, repeatable // --catalog an orchestrator_capabilities dump to check the sheet's targets against // --json // Node 20 or later, no dependencies. -import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { join } from "node:path"; +import { join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; 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, expandHome, 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"; @@ -38,7 +39,18 @@ const HARNESSES = { const USAGE = "usage: t3-pstack [--source ] " + - "[--claude-home ]... [--codex-home ]... [--catalog ] [--json]"; + "[--project | --local ] [--claude-home ]... [--codex-home ]... [--catalog ] [--json]"; + +// The project a `--project` or `--local` run installs for, by its real path, +// as both CLIs record it. +function projectTarget(values, home) { + if (values.project !== undefined && values.local !== undefined) throw new Error("--project and --local name one project between them"); + const scope = values.local !== undefined ? "local" : values.project !== undefined ? "project" : null; + if (!scope) return null; + const path = resolve(expandHome(values[scope], home)); + if (!statSync(path, { throwIfNoEntry: false })?.isDirectory()) throw new Error(`--${scope} ${values[scope]}: not a directory`); + return { scope, dir: realpathSync(path) }; +} // What to converge on comes from the plugin this run installs: the local // source, or else this package, which is the marketplace's own repo. `skills` @@ -64,6 +76,8 @@ async function run(argv, env, home) { allowPositionals: true, options: { source: { type: "string" }, + project: { type: "string" }, + local: { type: "string" }, "claude-home": { type: "string", multiple: true, default: [] }, "codex-home": { type: "string", multiple: true, default: [] }, catalog: { type: "string" }, @@ -74,9 +88,11 @@ async function run(argv, env, home) { if (positionals.length !== 1 || !["install", "update", "doctor"].includes(command)) return { usage: true }; const source = parseSource(values.source ?? HOME_REPO, home); + const project = projectTarget(values, home); + const hint = installHint(project); 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 context = { source, trusted, version: facts.version, skills: facts.skills, codexHook: facts.codexHook, project }; const { homes, settings } = discoverHomes({ env, home, @@ -85,7 +101,7 @@ async function run(argv, env, home) { }); const catalog = values.catalog ? readJson(values.catalog) : null; - const report = { command, version: facts.version, source: source.arg, settings, homes: [] }; + const report = { command, version: facts.version, source: source.arg, project, settings, homes: [] }; for (const target of homes) { const harness = HARNESSES[target.harness]; const row = { harness: target.harness, dir: target.dir, from: target.from, steps: [], checks: [] }; @@ -95,14 +111,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,7 +126,7 @@ 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 })); const all = [...report.homes.flatMap((row) => row.checks), ...report.sheet.checks]; @@ -119,8 +135,9 @@ async function run(argv, env, home) { return { report, json: values.json }; } -function render({ command, version, source, settings, homes, sheet, failed, warnings }, home) { +function render({ command, version, source, project, settings, homes, sheet, failed, warnings }, home) { const lines = [`t3-pstack ${version} from ${source}`]; + if (project) lines.push(`Project: ${project.dir}, at ${project.scope} scope`); lines.push(`T3 settings: ${settings.path}${settings.error ? ` (${settings.error})` : `, ${settings.instances} Claude Code or Codex instances`}`); const section = (title, steps, checks) => { lines.push("", title); diff --git a/tools/install/claude.mjs b/tools/install/claude.mjs index 52956f9..d2946bc 100644 --- a/tools/install/claude.mjs +++ b/tools/install/claude.mjs @@ -1,11 +1,11 @@ -// Claude Code: the marketplace plugin at user scope, in one config home. -import { constants, copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs"; -import { join } from "node:path"; +// Claude Code: the marketplace plugin in one config home, at the requested scope. +import { constants, copyFileSync, existsSync, mkdirSync, readdirSync, realpathSync } from "node:fs"; +import { dirname, join } from "node:path"; import { check, claudeSource, - INSTALL_HINT, + installHint, isPlainCopy, MARKETPLACE, marketplaceCheck, @@ -20,71 +20,110 @@ import { const readOr = (path, fallback) => (existsSync(path) ? readJson(path) : fallback); const RECORDS = "plugins/installed_plugins.json"; -function read(dir) { - const settings = readOr(join(dir, "settings.json"), {}); +// The settings file each scope writes: the home's own, or one in the project. +export const scopeSettings = (dir, project) => + !project + ? join(dir, "settings.json") + : join(project.dir, ".claude", project.scope === "local" ? "settings.local.json" : "settings.json"); + +const real = (path) => (existsSync(path) ? realpathSync(path) : path); + +// `settings` is the scope's settings file, where the plugin is enabled and +// the marketplace's auto-update is turned on. `record` is the install that +// scope uses: the user-scope one, or the project's at its scope. +function read(dir, project) { + const path = scopeSettings(dir, project); + const settings = readOr(path, {}); const known = readOr(join(dir, "plugins/known_marketplaces.json"), {}); const records = readOr(join(dir, RECORDS), {}).plugins?.[PLUGIN_ID] ?? []; const user = records.find((r) => r.scope === "user"); - const declared = settings.extraKnownMarketplaces?.[MARKETPLACE]; + const record = project + ? records.find((r) => r.scope === project.scope && r.projectPath && real(r.projectPath) === real(project.dir)) + : user; + const own = settings.extraKnownMarketplaces?.[MARKETPLACE]; + // Claude Code merges every layer's declarations. A project install also + // sees the home's own and the project's other settings file. + const other = project && scopeSettings(dir, { ...project, scope: project.scope === "local" ? "project" : "local" }); + const others = project ? [join(dir, "settings.json"), other] : []; + const declared = [own, ...others.map((file) => readOr(file, {}).extraKnownMarketplaces?.[MARKETPLACE])]; return { + path, settings, - marketplace: declared ?? known[MARKETPLACE], - // The source settings.json declares and the one Claude Code last fetched. - sources: [declared, known[MARKETPLACE]].filter((m) => m?.source).map((m) => claudeSource(m.source)), + declaredHere: own !== undefined, + // Each home fetches the marketplace itself, whatever the project declares. + known: known[MARKETPLACE] !== undefined, + marketplace: declared.find(Boolean) ?? known[MARKETPLACE], + // The sources the settings declare and the one Claude Code last fetched. + sources: [...declared, known[MARKETPLACE]].filter((m) => m?.source).map((m) => claudeSource(m.source)), + record, user, stale: user ? records.filter((r) => isStale(r, user)) : [], enabled: settings.enabledPlugins?.[PLUGIN_ID] === true, + // The project's local settings win over its shared ones. + localOff: + project?.scope === "project" && + readOr(scopeSettings(dir, { ...project, scope: "local" }), {}).enabledPlugins?.[PLUGIN_ID] === false, }; } const isStale = (record, user) => record.scope !== "user" && (record.version !== user.version || record.installPath !== user.installPath); -const intact = (state, skills) => state.user !== undefined && pluginIntact(state.user.installPath, ".claude-plugin/plugin.json", skills); +const intact = (state, skills) => + state.record !== undefined && pluginIntact(state.record.installPath, ".claude-plugin/plugin.json", skills); // Third-party marketplaces default to no auto-update; the /plugin toggle // writes this key, and Claude Code copies it into known_marketplaces.json at // its next start. A directory source loads in place and has nothing to fetch. const autoUpdates = (marketplace) => marketplace?.autoUpdate === true || marketplace?.source?.source === "directory"; -export function installClaude(home, { source, trusted, version, skills }) { +export function installClaude(home, { source, trusted, version, skills, project }) { const steps = []; - const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CLAUDE_CONFIG_DIR: home.dir }); + const scope = project?.scope ?? "user"; + // From the project, so `--scope project` and `--scope local` write its files. + const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CLAUDE_CONFIG_DIR: home.dir }, project?.dir); + const state = () => read(home.dir, project); mkdirSync(home.dir, { recursive: true }); - if (!read(home.dir).sources.length) { - plugin("marketplace", "add", source.arg); - steps.push(`added the ${MARKETPLACE} marketplace from ${source.arg}`); + const before = state(); + // A project install declares the marketplace in the project's own settings + // too, so that file names where its plugin comes from. Never beside a + // marketplace of another source. + if (!before.sources.length || (project && (!before.declaredHere || !before.known) && marketplaceCheck(before.sources, trusted).ok)) { + plugin("marketplace", "add", ...(project ? ["--scope", scope] : []), source.arg); + steps.push(`added the ${MARKETPLACE} marketplace from ${source.arg}${project ? ` to ${before.path}` : ""}`); } // 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 (!state.user) { - plugin("install", PLUGIN_ID, "--scope", "user"); - steps.push(`installed ${read(home.dir).user?.version}`); - } else if (state.user.version !== version) { + let now = state(); + if (!marketplaceCheck(now.sources, trusted).ok) return steps; + if (!now.record) { + plugin("install", PLUGIN_ID, "--scope", scope); + steps.push(`installed ${state().record?.version}${project ? ` at ${scope} scope` : ""}`); + } else if (now.record.version !== version) { // `plugin update` reads the marketplace as last fetched. plugin("marketplace", "update", MARKETPLACE); - plugin("update", PLUGIN_ID, "--scope", "user"); - steps.push(updated(state.user.version, read(home.dir).user?.version, version)); - } else if (!intact(state, skills)) { - plugin("install", PLUGIN_ID, "--scope", "user"); - steps.push(`reinstalled ${read(home.dir).user?.version}, whose files were missing`); + plugin("update", PLUGIN_ID, "--scope", scope); + steps.push(updated(now.record.version, state().record?.version, version)); + } else if (!intact(now, skills)) { + plugin("install", PLUGIN_ID, "--scope", scope); + steps.push(`reinstalled ${state().record?.version}, whose files were missing`); } - if (!read(home.dir).enabled) { - plugin("enable", PLUGIN_ID, "--scope", "user"); - steps.push("enabled it"); + if (!state().enabled) { + plugin("enable", PLUGIN_ID, "--scope", scope); + steps.push(project ? `enabled it in ${now.path}` : "enabled it"); } - state = read(home.dir); - if (state.marketplace && !autoUpdates(state.marketplace)) { - const declared = state.settings.extraKnownMarketplaces?.[MARKETPLACE] ?? { source: state.marketplace.source }; - state.settings.extraKnownMarketplaces = { - ...state.settings.extraKnownMarketplaces, + now = state(); + if (now.marketplace && !autoUpdates(now.marketplace)) { + const declared = now.settings.extraKnownMarketplaces?.[MARKETPLACE] ?? { source: now.marketplace.source }; + now.settings.extraKnownMarketplaces = { + ...now.settings.extraKnownMarketplaces, [MARKETPLACE]: { ...declared, autoUpdate: true }, }; - writeAtomic(join(home.dir, "settings.json"), JSON.stringify(state.settings, null, 2) + "\n"); - steps.push("turned on marketplace auto-update"); + mkdirSync(dirname(now.path), { recursive: true }); + writeAtomic(now.path, JSON.stringify(now.settings, null, 2) + "\n"); + steps.push(project ? `turned on marketplace auto-update in ${now.path}` : "turned on marketplace auto-update"); } - steps.push(...syncScopedRecords(home.dir)); + // A project install keeps the project's own record as it is. + if (!project) steps.push(...syncScopedRecords(home.dir)); return steps; } @@ -115,40 +154,59 @@ export function syncScopedRecords(dir) { return [...steps, `backed up ${RECORDS} to ${backup}`]; } -export function doctorClaude(home, { version, skills, trusted }) { - const state = read(home.dir); - const installed = state.user?.version; +export function doctorClaude(home, { version, skills, trusted, project }) { + const state = read(home.dir, project); + const hint = installHint(project); + const installed = state.record?.version; const whole = intact(state, skills); - const skillsDir = join(home.dir, "skills"); - const plain = existsSync(skillsDir) - ? readdirSync(skillsDir).filter((name) => isPlainCopy(name, join(skillsDir, name, "SKILL.md"), skills)) - : []; + const plain = [join(home.dir, "skills"), ...(project ? [join(project.dir, ".claude/skills")] : [])].flatMap((skillsDir) => + existsSync(skillsDir) + ? readdirSync(skillsDir) + .filter((name) => isPlainCopy(name, join(skillsDir, name, "SKILL.md"), skills)) + .map((name) => join(skillsDir, name)) + : [], + ); + const where = project ? `, in ${state.path}` : ""; return [ - marketplaceCheck(state.sources, trusted, `remove it with \`CLAUDE_CONFIG_DIR=${home.dir} claude plugin marketplace remove ${MARKETPLACE}\``), + marketplaceCheck( + state.sources, + trusted, + `remove it with \`CLAUDE_CONFIG_DIR=${home.dir} claude plugin marketplace remove ${MARKETPLACE}\``, + hint, + ), check( "installed", installed === version && whole, !installed - ? "not installed" + ? project + ? `not installed at ${project.scope} scope for ${project.dir}` + : "not installed" : installed !== version ? `${installed}, expected ${version}` : whole ? installed - : `${installed}, but its files at ${state.user.installPath} are missing`, - INSTALL_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( - "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, + : `${installed}, but its files at ${state.record.installPath} are missing`, + hint, ), + state.localOff + ? check("enabled", false, `${scopeSettings(home.dir, { ...project, scope: "local" })} turns it off`, `remove that entry, or ${installHint({ ...project, scope: "local" })}`) + : check("enabled", state.enabled, `${state.enabled ? "yes" : "no"}${where}`, hint), + check("marketplace auto-update", autoUpdates(state.marketplace), state.marketplace ? String(autoUpdates(state.marketplace)) : "no marketplace", hint), + // A project install leaves other records alone, so only the global one checks them. + ...(project + ? [] + : [ + 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", + hint, + ), + ]), check( "no plain copies of plugin skills", plain.length === 0, - plain.length ? plain.map((name) => join(skillsDir, name)).join(", ") : "none", + plain.length ? plain.join(", ") : "none", "delete these folders; the plugin ships the same skills", ), ]; diff --git a/tools/install/codex.mjs b/tools/install/codex.mjs index 1ebccab..94b6cdf 100644 --- a/tools/install/codex.mjs +++ b/tools/install/codex.mjs @@ -8,6 +8,10 @@ // its command runs. Codex's own trust covers the entry alone, and the script // changes with each release, so the marketplace source check is what vouches // for the script. +// +// Codex has no plugin scopes, so a project install enables the plugin in the +// project's .codex/config.toml, a layer Codex reads only for a project the +// home trusts. import { spawn } from "node:child_process"; import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; @@ -16,7 +20,7 @@ import { hookDigest } from "../plugin.mjs"; import { check, codexSource, - INSTALL_HINT, + installHint, isPlainCopy, launch, MARKETPLACE, @@ -26,6 +30,7 @@ import { pluginIntact, runCli, updated, + writeAtomic, } from "./common.mjs"; const REQUEST_TIMEOUT = 60_000; @@ -126,25 +131,67 @@ function installedVersions(dir) { const marketplaceSources = (config) => (config.marketplaces?.[MARKETPLACE] ? [codexSource(config.marketplaces[MARKETPLACE])] : []); -async function pluginState(server, dir, skills) { +// A project's .codex layers, which Codex reads out even while the project is +// not trusted yet, so a marketplace one names is seen before it loads. +const projectLayers = async (server, project) => + project ? (await server.request("config/read", { cwd: project.dir, includeLayers: true })).layers.filter((l) => l.name.type === "project") : []; +const allSources = (config, layers) => [config, ...layers.map((l) => l.config)].flatMap(marketplaceSources); + +const listedPlugin = async (server, cwd) => { + const { marketplaces = [] } = await server.request("plugin/list", cwd ? { cwds: [cwd] } : {}); + return marketplaces.flatMap((m) => m.plugins ?? []).find((p) => p.id === PLUGIN_ID); +}; + +// `sources` adds the project's marketplaces to the home's `homeSources`. +// `enabled` is as a session sees it: from the project when there is one. +// `homeEnabled` is the home's own setting, and `layer` the project's +// .codex/config.toml as Codex reads it, with `disabledReason` set while the +// project is not trusted. +async function pluginState(server, dir, skills, project) { const { config } = await server.request("config/read", {}); - const { marketplaces = [] } = await server.request("plugin/list", {}); - const plugin = marketplaces.flatMap((m) => m.plugins ?? []).find((p) => p.id === PLUGIN_ID); + const plugin = await listedPlugin(server); const versions = installedVersions(dir); - return { - sources: marketplaceSources(config), + const layers = await projectLayers(server, project); + const state = { + homeSources: marketplaceSources(config), + sources: allSources(config, layers), installed: plugin?.installed === true, enabled: plugin?.enabled === true, + homeEnabled: plugin?.enabled === true, versions, intact: versions.length === 1 && pluginIntact(join(cacheDir(dir), versions[0]), ".codex-plugin/plugin.json", skills), }; + if (project) { + const folder = join(project.dir, ".codex"); + state.layer = layers.find((l) => l.name.dotCodexFolder === folder); + // The layer says so when it exists; a trust the home gives a parent + // directory, such as the repository's root, counts too. + state.trusted = state.layer ? !state.layer.disabledReason : config.projects?.[project.dir]?.trust_level === "trusted"; + state.enabled = (await listedPlugin(server, project.dir))?.enabled === true; + } + return state; } -async function pluginHooks(server) { - const { data } = await server.request("hooks/list", {}); +async function pluginHooks(server, project) { + const { data } = await server.request("hooks/list", project ? { cwds: [project.dir] } : {}); return data.flatMap((entry) => entry.hooks).filter((hook) => hook.pluginId === PLUGIN_ID); } +// Codex writes only the home's config, so the installer appends the plugin's +// table to a project's config.toml. A file that already names the plugin or +// has a `plugins` value, which an inline table cannot be extended from, is the +// user's to edit. `parsed` is the file as Codex read it. +const TABLE = `[plugins."${PLUGIN_ID}"]`; +export function enableInProjectConfig(path, parsed) { + const text = existsSync(path) ? readFileSync(path, "utf8") : ""; + if (text.includes(PLUGIN_ID) || parsed?.plugins !== undefined) { + throw new Error(`${path} already has a plugins entry; set \`enabled = true\` under ${TABLE} there`); + } + const gap = !text || text.endsWith("\n\n") ? "" : text.endsWith("\n") ? "\n" : "\n\n"; + mkdirSync(join(path, ".."), { recursive: true }); + writeAtomic(path, `${text}${gap}${TABLE}\nenabled = true\n`); +} + // A file's text, read once without following a symlink, or null when it is a // symlink or not a regular file. function readRegularFile(path) { @@ -182,28 +229,31 @@ function isPinned(hook, pin) { // Why `codex plugin add` runs: a first install, an update, a cache with // missing files, or a disabled plugin, since Codex has no enable command. // Null when the home is already at `version`. -function codexStep(state, version) { +// A project install enables it in the project instead. +function codexStep(state, version, project) { if (!state.installed) return "install"; if (state.versions.length && state.versions.join() !== version) return "update"; if (!state.intact) return "repair"; - if (!state.enabled) return "enable"; + if (!state.enabled && !project) return "enable"; return null; } -export async function installCodex(home, { source, trusted, version, skills, codexHook }) { +export async function installCodex(home, { source, trusted, version, skills, codexHook, project }) { const steps = []; const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CODEX_HOME: home.dir }); - const state = () => withServer(home, (server) => pluginState(server, home.dir, skills)); + const state = () => withServer(home, (server) => pluginState(server, home.dir, skills, project)); mkdirSync(home.dir, { recursive: true }); let before = await state(); - if (!before.sources.length) { + // A project's marketplace of another source stops it before the home gains one. + if (before.sources.length && !marketplaceCheck(before.sources, trusted).ok) return steps; + if (!before.homeSources.length) { plugin("marketplace", "add", source.arg); steps.push(`added the ${MARKETPLACE} marketplace from ${source.arg}`); before = await state(); } // Nothing installs from a marketplace of another source; doctor names it. if (!marketplaceCheck(before.sources, trusted).ok) return steps; - const step = codexStep(before, version); + const step = codexStep(before, version, project); if (step) { // A local marketplace is read in place; a Git one is a snapshot to refresh. if (step !== "enable" && !source.local) plugin("marketplace", "upgrade", MARKETPLACE); @@ -217,8 +267,32 @@ export async function installCodex(home, { source, trusted, version, skills, cod enable: "enabled it", }[step], ); + // `codex plugin add` enables the plugin in the home; a project install + // puts back the home's own setting. + if (project && !before.homeEnabled) { + await withServer(home, (server) => writeHome(server, "plugins", { [PLUGIN_ID]: { enabled: false } })); + if (step === "install") steps.push("left it disabled outside the projects that enable it"); + } + } + if (project) steps.push(...(await enableInProject(home, project, before))); + steps.push(...(await withServer(home, (server) => trustHooks(server, trusted, codexHook, project)))); + return steps; +} + +const writeHome = (server, keyPath, value) => server.request("config/value/write", { keyPath, mergeStrategy: "upsert", value }); + +// Trusts the project in the home, then enables the plugin in its config.toml. +async function enableInProject(home, project, state) { + const steps = []; + if (!state.trusted) { + await withServer(home, (server) => writeHome(server, "projects", { [project.dir]: { trust_level: "trusted" } })); + steps.push(`trusted ${project.dir} in ${join(home.dir, "config.toml")}, so Codex reads its .codex/config.toml`); + } + if (state.layer?.config?.plugins?.[PLUGIN_ID]?.enabled !== true) { + const path = join(project.dir, ".codex/config.toml"); + enableInProjectConfig(path, state.layer?.config); + steps.push(`enabled it in ${path}`); } - steps.push(...(await withServer(home, (server) => trustHooks(server, trusted, codexHook)))); return steps; } @@ -229,11 +303,11 @@ export async function installCodex(home, { source, trusted, version, skills, cod // a hooks file that changed since the check, and then the trust is taken // back. `upsert`: `replace` would drop the user's other trusted hooks. An // entry from any other hooks file stays untrusted, and doctor names it. -async function trustHooks(server, trusted, pin) { +async function trustHooks(server, trusted, pin, project) { const { config, layers } = await server.request("config/read", { includeLayers: true }); - if (!marketplaceCheck(marketplaceSources(config), trusted).ok) return []; + if (!marketplaceCheck(allSources(config, await projectLayers(server, project)), trusted).ok) return []; const user = layers.find((layer) => layer.name.type === "user"); - const pending = (await pluginHooks(server)).filter( + const pending = (await pluginHooks(server, project)).filter( (h) => (h.trustStatus === "untrusted" || h.trustStatus === "modified") && isPinned(h, pin), ); if (!pending.length) return []; @@ -243,8 +317,8 @@ async function trustHooks(server, trusted, pin) { value: Object.fromEntries(pending.map((h) => [h.key, { trusted_hash: h.currentHash }])), expectedVersion: user.version, }); - const listed = new Map((await pluginHooks(server)).map((h) => [h.key, h])); - const now = (h) => listed.get(h.key) ?? {}; + const after = new Map((await pluginHooks(server, project)).map((h) => [h.key, h])); + const now = (h) => after.get(h.key) ?? {}; const same = (h) => now(h).command === h.command && now(h).currentHash === h.currentHash; const failed = pending.filter((h) => now(h).trustStatus !== "trusted" || !same(h)); if (failed.length) { @@ -255,16 +329,32 @@ 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 }) { +// Why a project's sessions would not load the plugin, or null when they do. +function projectProblem(state, project) { + const path = join(project.dir, ".codex/config.toml"); + if (state.layer?.config?.plugins?.[PLUGIN_ID]?.enabled !== true) return `not enabled in ${path}`; + if (!state.trusted) return `${project.dir} is not a trusted project, so Codex ignores ${path}`; + if (!state.enabled) return `${path} enables it, but a closer .codex/config.toml or a managed layer turns it off`; + return null; +} + +export async function doctorCodex(home, { version, skills, trusted, codexHook, project }) { + const hint = installHint(project); return withServer(home, async (server) => { - const state = await pluginState(server, home.dir, skills); - const hooks = await pluginHooks(server); - const listed = (await server.request("skills/list", { cwds: [neutralCwd()] })).data.flatMap((entry) => entry.skills); + const state = await pluginState(server, home.dir, skills, project); + const hooks = await pluginHooks(server, project); + const listed = (await server.request("skills/list", { cwds: [project?.dir ?? neutralCwd()] })).data.flatMap((entry) => entry.skills); const plain = listed.filter((skill) => isPlainCopy(skill.name, skill.path, skills)).map((skill) => skill.path); const installed = state.versions.join(", "); const good = (h) => h.trustStatus === "trusted" && isPinned(h, codexHook); + const problem = project && projectProblem(state, project); return [ - marketplaceCheck(state.sources, trusted, `remove it with \`CODEX_HOME=${home.dir} codex plugin marketplace remove ${MARKETPLACE}\``), + marketplaceCheck( + state.sources, + trusted, + `remove it with \`CODEX_HOME=${home.dir} codex plugin marketplace remove ${MARKETPLACE}\``, + hint, + ), check( "installed", state.installed && installed === version && state.intact, @@ -275,16 +365,18 @@ 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), + project + ? check("enabled in the project", !problem, problem ?? `yes, in ${join(project.dir, ".codex/config.toml")}`, 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 f78de9f..44914ea 100644 --- a/tools/install/common.mjs +++ b/tools/install/common.mjs @@ -12,6 +12,10 @@ export const MARKETPLACE = "t3-pstack"; export const HOME_REPO = "chhoumann/t3-pstack"; export const INSTALL_HINT = "rerun `npx -y github:chhoumann/t3-pstack install`"; +// The rerun a fix names: the global one, or the project install's own flag. +export const installHint = (project) => + project ? `rerun \`npx -y github:chhoumann/t3-pstack install --${project.scope} ${project.dir}\`` : INSTALL_HINT; + // `~`, `~/` and `~\` expand as T3 expands them (provider-core // pathExpansion.ts). `~user` does not. export const expandHome = (path, home) => (path === "~" ? home : /^~[/\\]/.test(path) ? join(home, path.slice(2)) : path); @@ -60,15 +64,15 @@ export function codexSource(entry) { // 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, trusted, remove, hint = INSTALL_HINT) { + 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}`, ); } @@ -82,10 +86,10 @@ export const isPlainCopy = (name, skillFile, skills) => export const pluginIntact = (root, manifest, skills) => existsSync(join(root, manifest)) && [...skills.keys()].every((name) => existsSync(join(root, "skills", name, "SKILL.md"))); -// Every CLI runs from an empty directory. Claude Code reads `/.claude` -// as project settings, and from the home folder that is the main home's -// settings.json, so the user's own working directory could leak into the -// home being installed. +// Every CLI runs from an empty directory unless the caller names a `cwd`. +// Claude Code reads `/.claude` as project settings, and from the home +// folder that is the main home's settings.json, so the user's own working +// directory could leak into the home being installed. let neutral; export function neutralCwd() { if (!neutral) { @@ -136,12 +140,12 @@ export function launch(bin, args) { }; } -export function runCli(bin, args, env) { +export function runCli(bin, args, env, cwd = neutralCwd()) { const run = launch(bin, args); try { return execFileSync(run.file, run.args, { ...run.options, - cwd: neutralCwd(), + cwd, env: { ...process.env, ...env }, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"],