diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f89f359c..21fa7c3e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "t3-pstack", "source": "./plugins/pstack", "description": "pstack for T3 Code: rigorous agent workflows whose panels and fan-outs run across model families through T3. Original pstack by Lauren Tan (poteto).", - "version": "2.0.0-rc.2" + "version": "2.0.0-rc.3" } ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb6ff278..25821ab3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,6 +46,8 @@ jobs: run: bun test --timeout 10000 tests/session-hook-windows.test.mjs env: PSTACK_REQUIRE_WINDOWS_HOOK: "1" + - name: Test the installer's CLI launch + run: bun test --timeout 30000 tests/install-cli.test.mjs forks: name: Fork registry diff --git a/CHANGES.md b/CHANGES.md index e785dbf0..60af7219 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -2,6 +2,30 @@ This file is the release changelog, with one `## - ` entry per release, newest first. The Cursor-to-T3 rewrite rules live in [`tools/substitutions.json`](tools/substitutions.json), and the [sync boundary](CONTRIBUTING.md#the-sync-boundary) in `CONTRIBUTING.md` defines how a change to upstream's skill content is declared. +## 2.0.0-rc.3 - One installer and doctor for Claude Code and Codex, a trusted Codex hook, and one sheet per machine + +`npx -y github:chhoumann/t3-pstack install` installs or updates t3-pstack in every Claude Code and Codex home T3 can run, and `doctor` checks them. The homes are `~/.claude` and `~/.codex`, `CLAUDE_CONFIG_DIR` and `CODEX_HOME`, each `claudeAgent` and `codex` provider instance in T3's `${T3CODE_HOME:-~/.t3}/userdata/settings.json`, disabled ones included, and any home named with `--claude-home` or `--codex-home`. Each step converges, so a second run changes nothing. Each home's `t3-pstack` marketplace must come from `chhoumann/t3-pstack` or from the run's `--source`. A home whose marketplace has another source, a counterfeit or a local `--source` from an earlier run, gets nothing installed and no hook trusted, and `doctor` fails it with that source. + +Until now the Codex session-start hook never ran under T3. Codex 0.162.1 runs a plugin hook only once `hooks.state."<key>".trusted_hash` in `config.toml` matches the hook's entry, and `codex app-server`, which T3 runs, never asks for that trust. The installer trusts it on every run the way Codex's own TUI does: `hooks/list`, then `config/value/write` with the hash Codex reports and `mergeStrategy: "upsert"`, so the user's other trusted hooks stay. It trusts an entry only when it comes from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins, and when the event, matcher, and command Codex lists are an entry of that file's text, read once and never through a symlink. It checks the marketplace source, lists the hooks, and writes the trust in one app-server session. The write names the `config.toml` version the source check read, so Codex refuses it when the file changed in between. When Codex then lists a different entry, the installer takes the trust back and fails the home. In a throwaway home with the plugin installed by hand, a T3-shaped app-server session fired no hook event and its rollout held no routing text. After the installer, the same session fired `hook/started` and `hook/completed`, and its rollout held the routing text as one developer message. `tests/install-e2e.test.mjs` runs that check with the real CLIs. + +Codex trusts a hook by a hash of its entry, and a Git marketplace upgrades the plugin at every app-server start, so a changed entry would stop routing in every home with no announcement. `tools/frozen-hooks.json` now pins both hooks files, and `bun tools/generate.mjs --check` fetches `origin/main`, which the marketplaces deliver, and compares each with it. A file that differs fails the check unless its pin has the new digest, its `changedIn` names this `VERSION`, that `VERSION` is newer than main's, and this release's entry here tells users to rerun the installer. Neither file changes in this release. + +The model sheet moves to `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, because provider instance IDs differ per machine, not per harness. The installer moves an existing `t3-pstack-models.md` there once and leaves a symlink at each home's old path, so a release before this one still reads the same sheet. Until the installer runs, the hook, the routing text, the skills' Models sections, and setup-pstack fall back to the old per-home sheet. + +| Change | 2.0.0-rc.2 | 2.0.0-rc.3 | Source | +| --- | --- | --- | --- | +| Install and update | `claude plugin` and `codex plugin` commands by hand, in each home. | `npx -y github:chhoumann/t3-pstack install`, or `update`, the same command, for every home T3 can run. It runs `main`'s latest commit, the channel the marketplaces auto-update from, and `#v<version>` pins one release. A listed home that does not exist yet is created, and a home whose CLI is not on `PATH` is skipped with a warning. A home already at the installer's version gets only the steps it still lacks, such as the Codex trust, and one whose plugin files are missing is reinstalled. A file it replaces keeps its mode and, where the system allows, its owner. | T3 installer | +| Marketplace source | Whatever each home was given. | `chhoumann/t3-pstack`, or the run's `--source`, which reads `owner/repo` as GitHub as both CLIs do, so a local directory needs a path such as `./t3-pstack`. Nothing installs from a `t3-pstack` marketplace with any other source, and none of its hooks is trusted. | T3 installer | +| Codex session-start hook | Never ran under T3, because the app-server never asks for hook trust. The README said to trust it with `/hooks` in the Codex CLI. | The installer trusts it in every Codex home on every run, with the hash Codex reports, once its hooks file is the one this release pins and the listed entry is one of that file's. Source check, listing, and trust write share one app-server session, and a write after `config.toml` changed is refused. The home fails unless Codex then reports the same entry `trusted`, and otherwise the trust is taken back. A rerun writes nothing. | T3 installer | +| `doctor` | None. | Reports, per home, the marketplace's source, the installed version against the installer's and whether its files are all there, whether the plugin is enabled, the Codex hook's trust and pin, Claude Code records at another scope that pin an old version, copies of the plugin's skills whose `SKILL.md` matches the plugin's, and the model sheet: present, parsed, and with `--catalog <file>` checked against an `orchestrator_capabilities` dump. It exits 1 on any problem and prints a fix for each. A missing sheet is a warning. | T3 installer | +| Claude Code marketplace auto-update | Off, Claude Code's default for a third-party marketplace. | The installer sets `extraKnownMarketplaces.t3-pstack.autoUpdate` in each home's `settings.json`. A home behind the installer's version gets `claude plugin marketplace update` and `claude plugin update`. | T3 installer | +| A stale project-scope record | A second Claude Code config home could keep a project-scope `t3-pstack@t3-pstack` record for the home folder at an old version, because Claude Code reads `~/.claude/settings.json` as that folder's project settings. | The installer backs up that home's `plugins/installed_plugins.json` and brings each stale record at another scope to the user-scope install. It never runs `claude plugin uninstall --scope project`, which would edit the main home's settings. | T3 installer | +| Model sheet location | `t3-pstack-models.md` in each host's config directory, linked between hosts by hand. | `~/.config/t3-pstack/models.md`, one per machine. The installer moves the old sheet there once and links each home's old path to it. A home sheet that links to a file outside every home, such as one in a dotfiles repository, becomes the machine sheet's link target instead of a copy. A home sheet that differs is left, and `doctor` names it. A home sheet that links to another file is left too. | T3 adaptation | +| setup-pstack | Wrote the host's sheet and linked the other host's path to it. | Writes the machine sheet, reads the old per-home sheet when the machine sheet is missing, and links this host's old path to the machine sheet. | Declared fork | +| A changed hooks file | `session-hook.test.mjs` pinned the 1.0.14 entries literally. | `tools/frozen-hooks.json` pins each hooks file's digest and the release that last changed it. The generator check fetches `origin/main` and compares each hooks file with it. It fails on an unpinned change, on a repinned one whose `changedIn` is not this `VERSION` or whose `VERSION` is not newer than main's, and on one whose release entry does not tell users to rerun the installer. | T3 adaptation | +| Windows | Not tested. | The installer starts the `.cmd` shims npm installs for `claude` and `codex` through `cmd.exe`, with each argument quoted and escaped as cross-spawn does, and reads a full Windows path in `binaryPath` as a path. CI's Windows job runs the launch tests. | T3 installer | +| `t3-sheet.mjs` without a catalog | A catalog was required. | `checkSheet` with no catalog checks the grammar only. `doctor` uses it. | T3 adaptation | + ## 2.0.0-rc.2 - Lauren's 0.15.24, four of Michael's rules, and three watch-pr fixes t3-pstack moves to Lauren Tan's pstack 0.15.24, cursor/plugins `1e5c90a`, for all three components. Only pstack changed upstream: the autopilot playbooks catch the operator up through `/ketchup` ([pstack #616](https://github.com/cursor/plugins/pull/616)), a new `/sharpen` skill aims a task before it starts ([pstack #615](https://github.com/cursor/plugins/pull/615)), and the version moves to 0.15.24 ([pstack #617](https://github.com/cursor/plugins/pull/617)). `bun tools/sync.mjs` wrote every change cleanly, and at `1e5c90a` each component's dry run reports no undeclared fork, no denylist hit, and no substitution that matches nothing. diff --git a/CONTEXT.md b/CONTEXT.md index f510a749..077ea88c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -5,7 +5,9 @@ Names for the concepts this repo's design discussions keep reaching for. Archite - **Host** - the harness a T3 thread runs on and that loads the plugin: Claude Code (`.claude-plugin/`) or Codex (`.codex-plugin/`). Both read the same `plugins/pstack/` tree. The skill text names each host's own form inline where the two differ, such as a skills directory or a question tool. - **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** - `t3-pstack-models.md` in the host's config directory, one role per line, each value a T3 entry (`<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs) or `inherit-parent`. `setup-pstack` writes it and `t3-sheet.mjs` checks it against `orchestrator_capabilities`. +- **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 (`<providerInstanceId>/<modelId>` plus `<optionId>=<value>` 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 <install|update|doctor>`. 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. +- **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`. - **Panel** - a role whose value is a list (`arena runners`, `architect runners`, `interrogate reviewers`, `arena cross-judge pool`). A panel spans at least two model families; a family is the first segment of a model ID's last `/` part. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d8b6d4ad..9e07dc68 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ bun tools/generate.mjs GIT_CONFIG_GLOBAL=/dev/null bun test tests/ --timeout 30000 ``` -The generator stamps `VERSION` into the Claude Code and Codex manifests, the marketplace entry, and `package.json`. It renders each model-consuming skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block from `plugins/pstack/models.json`, lists the plugin's agents in the Claude Code manifest, and copies the license and notice files into the installed plugin. It checks each skill's frontmatter, every local Markdown link, the plugin layout, agent frontmatter, the Codex marketplace, and both hooks files. CI runs `bun tools/generate.mjs --check`, which writes nothing and fails naming each generated file that is stale or orphaned, so commit the generator's output. `.pre-commit-config.yaml` runs the same check before each commit once you run `prek install` (or `pre-commit install`). +The generator stamps `VERSION` into the Claude Code and Codex manifests, the marketplace entry, and `package.json`. It renders each model-consuming skill's Models section, interrogate's reviewer table, and setup-pstack's sheet block from `plugins/pstack/models.json`, lists the plugin's agents in the Claude Code manifest, and copies the license and notice files into the installed plugin. It checks each skill's frontmatter, every local Markdown link, the plugin layout, agent frontmatter, the Codex marketplace, and both hooks files against their pins in `tools/frozen-hooks.json`. CI runs `bun tools/generate.mjs --check`, which writes nothing and fails naming each generated file that is stale or orphaned, so commit the generator's output. `.pre-commit-config.yaml` runs the same check before each commit once you run `prek install` (or `pre-commit install`). Change model defaults in `models.json`, never in a skill body. A role names a tier (`default`, `strongest`, or `panel`), and each tier holds T3 entries in the grammar `t3-sheet.mjs` checks. The panel must span two model families. A role may carry an `alias`, the label a sheet written before upstream renamed the role still uses. @@ -63,6 +63,12 @@ bunx prettier@3.6.2 --check watch-pr/settle.ts watch-pr/settle.test.ts resolve-c Upstream's scripts are not Prettier output, so the format check covers the port's own TypeScript only. +If you touched the installer (`tools/install.mjs` or `tools/install/`), also run its end-to-end test. It needs the `claude` and `codex` CLIs, runs in a throwaway `HOME`, and takes a few minutes: + +```shell +T3_PSTACK_E2E=1 GIT_CONFIG_GLOBAL=/dev/null bun test tests/install-e2e.test.mjs --timeout 300000 +``` + If you touched a workflow, audit it before pushing: ```shell @@ -75,7 +81,7 @@ uvx zizmor@1.29.0 --persona pedantic --min-severity low --collect all -- . - **A substitution that no longer matches upstream.** Update it to upstream's current sentence or delete it. - **`disable-model-invocation` on `poteto-mode`.** The session-start hook invokes it by name, and the flag makes the Skill tool refuse that. - **A root `plugins/pstack/plugin.json`.** Codex reads a plugin with upstream's Agent Plugins manifest as an Agent Plugin and truncates each injected SKILL.md to 8,000 bytes, which cuts `poteto-mode` off mid-file. `tools/upstream.json` excludes the file. -- **A changed hook entry.** Codex trusts a plugin hook by a hash of its entry, so changing a command, matcher, or timeout in `hooks/hooks.json` or `hooks/codex-hooks.json` silently stops routing until each user trusts it again. Change `session-start.sh` or the routing text instead. +- **A changed hooks file.** Codex trusts a plugin hook by a hash of its entry, and a Git marketplace delivers a new entry at the next app-server start, so any change to `hooks/hooks.json` or `hooks/codex-hooks.json` stops routing in every Codex home until the installer reruns. `tools/frozen-hooks.json` pins both. Change `session-start.sh` or the routing text instead. When an entry must change, pin its new digest with `changedIn` set to the release, and tell users to rerun the installer in that release's `CHANGES.md` entry. The check fetches `origin/main`, which the marketplaces deliver, and compares each hooks file with it, so a file that differs from main's needs a `VERSION` newer than main's and a `changedIn` that names it, and the installer trusts only the entry its own release pins. The fetch means the check needs the network. - **Stale generated output, or a missing `CHANGES.md` heading for `VERSION`.** - **An unresolved sync conflict marker** anywhere under `plugins/pstack`. - **A missing or escaping local Markdown link.** diff --git a/README.md b/README.md index 395a8120..2185d284 100644 --- a/README.md +++ b/README.md @@ -6,21 +6,21 @@ The skill text is Lauren's. A generated set of substitutions rewrites only what t3-pstack works only inside T3. -## Install - -| | Claude Code | Codex | -| --- | --- | --- | -| Plugin id | `t3-pstack@t3-pstack` | `t3-pstack@t3-pstack` | -| Marketplace repo | `chhoumann/t3-pstack` | `chhoumann/t3-pstack` | +## Install, update, and check ```shell -claude plugin marketplace add chhoumann/t3-pstack && claude plugin install t3-pstack@t3-pstack -codex plugin marketplace add chhoumann/t3-pstack && codex plugin add t3-pstack@t3-pstack +npx -y github:chhoumann/t3-pstack install +npx -y github:chhoumann/t3-pstack update +npx -y github:chhoumann/t3-pstack doctor ``` -Codex runs a plugin's session-start hook only after you trust it, through `/hooks` in the Codex CLI. +`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.3 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. + +`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 `t3-pstack-models.md` in the host's config directory, with one T3 target per role (`<providerInstanceId>/<modelId>` 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. +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 (`<providerInstanceId>/<modelId>` 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. ## Use diff --git a/SECURITY.md b/SECURITY.md index 6e6566b6..1c86a91e 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -5,3 +5,11 @@ Report a vulnerability privately, through [GitHub's private vulnerability report The skill tree, the hooks, the vendored bun scripts under `plugins/pstack/skills/poteto-mode/scripts`, and the CI workflows are all in scope. Anything that makes an agent run a command the user did not intend, exfiltrate a secret, or reach outside the repository it was pointed at counts, as does a supply-chain weakness in how this repository pins and installs its own dependencies. Findings in T3 Code, Claude Code, or Codex belong to their projects, and findings in the upstream project this repository syncs from belong upstream. Only the latest release on `main` receives fixes. Plugin auto-update installs by version number, so a fix ships as a new release rather than as a patch to an older one, and there are no maintained release branches to back-port to. + +## Threat model + +The installer installs nothing and trusts no hook in a home whose `t3-pstack` marketplace comes from anywhere but `chhoumann/t3-pstack` or the run's explicit `--source`. In Codex it trusts only t3-pstack's own session-start hook, from the hooks file the Codex manifest names, at the digest `tools/frozen-hooks.json` pins. Like Codex's own hook trust, it checks the hook's entry and not the script the entry runs, which changes with each release. The marketplace source is what vouches for the script. + +An attacker who can already write to the user's Claude Code or Codex home, or to the plugin cache inside it, is out of scope. That is the boundary Codex's hook trust has, since such an attacker can write `hooks.state` in `config.toml` themselves. The installer still closes the simple windows. It checks the source, lists the hooks, and writes the trust in one app-server session, reads the hooks file once and never through a symlink, and takes the trust back when the entry changes before Codex confirms it. + +`npx -y github:chhoumann/t3-pstack` runs the latest commit on `main`, the rolling channel the marketplaces auto-update from. `npx -y github:chhoumann/t3-pstack#v<version>` runs one release's installer from its tag. diff --git a/VERSION b/VERSION index c8f3a156..b87c4d41 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.0.0-rc.2 +2.0.0-rc.3 diff --git a/package.json b/package.json index 0ebe5793..ccfe4666 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { "name": "t3-pstack", "private": true, - "version": "2.0.0-rc.2", - "description": "Development tooling for t3-pstack, Lauren Tan's pstack rewritten in place for T3 Code. The plugin itself lives in plugins/pstack.", + "version": "2.0.0-rc.3", + "description": "The installer and development tooling for t3-pstack, Lauren Tan's pstack rewritten in place for T3 Code. The plugin itself lives in plugins/pstack.", "author": { "name": "Christian Bager Bach Houmann", "url": "https://github.com/chhoumann" @@ -10,6 +10,9 @@ "homepage": "https://github.com/chhoumann/t3-pstack", "repository": "https://github.com/chhoumann/t3-pstack", "license": "MIT", + "bin": { + "t3-pstack": "tools/install.mjs" + }, "devDependencies": { "typebox": "1.3.34" } diff --git a/plugins/pstack/.claude-plugin/plugin.json b/plugins/pstack/.claude-plugin/plugin.json index 17c9cdbb..f111b0a0 100644 --- a/plugins/pstack/.claude-plugin/plugin.json +++ b/plugins/pstack/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "t3-pstack", "displayName": "t3-pstack (pstack for T3 Code)", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "description": "pstack for T3 Code: Lauren Tan's pstack with its Cursor mechanics rewritten in place for T3, so subagents, panels, schedules, and PR watches dispatch through T3's orchestration and panels mix Claude, GPT, and Grok.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/.codex-plugin/plugin.json b/plugins/pstack/.codex-plugin/plugin.json index 3ac6d802..b6e2f37e 100644 --- a/plugins/pstack/.codex-plugin/plugin.json +++ b/plugins/pstack/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "t3-pstack", - "version": "2.0.0-rc.2", + "version": "2.0.0-rc.3", "description": "pstack for T3 Code on a Codex host: Lauren Tan's pstack with its Cursor mechanics rewritten in place for T3, so subagents, panels, schedules, and PR watches dispatch through T3's orchestration.", "author": { "name": "Christian Bager Bach Houmann", diff --git a/plugins/pstack/README.md b/plugins/pstack/README.md index 6d83ad85..912b20d7 100644 --- a/plugins/pstack/README.md +++ b/plugins/pstack/README.md @@ -8,7 +8,7 @@ Tell `poteto-mode` your goal and it runs the workflow that fits: reproduce and r - Skills: upstream's skill text, with its Cursor mechanics rewritten for T3. Each appears as `/t3-pstack:<name>`. - Agents: `poteto-agent`, `comment-sicko`, and the two thermo-nuclear reviewers, each read by path in a `delegate_task` brief. -- A session-start hook that tells each chat when to enter `poteto-mode`, unless your `t3-pstack-models.md` sheet turns it off. +- A session-start hook that tells each chat when to enter `poteto-mode`, unless your model sheet turns it off. - Local scripts for watching pull requests, orchestrating multi-phase plans, checking plans, and auditing worktrees. ## Data handling diff --git a/plugins/pstack/hooks/session-start-context.md b/plugins/pstack/hooks/session-start-context.md index 6d697248..f939d835 100644 --- a/plugins/pstack/hooks/session-start-context.md +++ b/plugins/pstack/hooks/session-start-context.md @@ -11,7 +11,7 @@ It routes to the right pstack skill from there. On Codex, open its SKILL.md whol When the intent is already specific, such as TDD, a design sketch, how or why something works, an arena, or an adversarial review, still enter through `t3-pstack:poteto-mode` and open that skill from the bundled path it names. The other pstack skills run only from poteto-mode or when the user types them. -This runs on T3: before the first subagent or panel in a session, read the `t3-pstack-models.md` sheet in this host's config directory. This chat runs the playbook itself. A step it says to delegate goes through `delegate_task` to that role's sheet entry, and the reply lists which provider and model ran each step. +This runs on T3: before the first subagent or panel in a session, read the model sheet, `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), or `t3-pstack-models.md` in this host's config directory when that file is missing. This chat runs the playbook itself. A step it says to delegate goes through `delegate_task` to that role's sheet entry, and the reply lists which provider and model ran each step. User instructions (CLAUDE.md, AGENTS.md, direct requests) take precedence. Other session-start mandates, such as superpowers, still apply. Their skill checks run as before, and when a task meets the criteria above they route implementation through poteto-mode. </EXTREMELY_IMPORTANT> diff --git a/plugins/pstack/hooks/session-start.ps1 b/plugins/pstack/hooks/session-start.ps1 index 2c59fa0f..cbe93744 100644 --- a/plugins/pstack/hooks/session-start.ps1 +++ b/plugins/pstack/hooks/session-start.ps1 @@ -1,10 +1,19 @@ $ErrorActionPreference = 'Stop' -$sheetRoot = $env:CODEX_HOME -if (-not $sheetRoot) { - $sheetRoot = Join-Path $env:USERPROFILE '.codex' +# One sheet per machine. Until the installer moves an older home's sheet +# there, that home's sheet still counts. +$configRoot = $env:XDG_CONFIG_HOME +if (-not $configRoot) { + $configRoot = Join-Path $env:USERPROFILE '.config' +} +$sheet = Join-Path $configRoot 't3-pstack\models.md' +if (-not (Test-Path -LiteralPath $sheet -PathType Leaf)) { + $sheetRoot = $env:CODEX_HOME + if (-not $sheetRoot) { + $sheetRoot = Join-Path $env:USERPROFILE '.codex' + } + $sheet = Join-Path $sheetRoot 't3-pstack-models.md' } -$sheet = Join-Path $sheetRoot 't3-pstack-models.md' $off = $false if (Test-Path -LiteralPath $sheet -PathType Leaf) { # An unreadable sheet leaves the hook on, as session-start.sh does. diff --git a/plugins/pstack/hooks/session-start.sh b/plugins/pstack/hooks/session-start.sh index 0042738b..5a4d91a7 100755 --- a/plugins/pstack/hooks/session-start.sh +++ b/plugins/pstack/hooks/session-start.sh @@ -5,11 +5,11 @@ set -eu # Literal plugin paths, so a static reader of the hooks files can follow them. case "${1:-}" in claude) - sheet="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/t3-pstack-models.md" + home_sheet="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/t3-pstack-models.md" reader="${CLAUDE_PLUGIN_ROOT}/skills/setup-pstack/scripts/read-sheet.sh" ;; codex) - sheet="${CODEX_HOME:-$HOME/.codex}/t3-pstack-models.md" + home_sheet="${CODEX_HOME:-$HOME/.codex}/t3-pstack-models.md" reader="${CLAUDE_PLUGIN_ROOT}/skills/setup-pstack/scripts/read-sheet.sh" ;; *) @@ -18,6 +18,11 @@ case "${1:-}" in ;; esac +# One sheet per machine. Until the installer moves an older home's sheet +# there, that home's sheet still counts. +sheet="${XDG_CONFIG_HOME:-$HOME/.config}/t3-pstack/models.md" +[ -f "$sheet" ] || sheet=$home_sheet + # A sheet that cannot be decoded counts as missing, so injection stays on. if ! { [ -f "$sheet" ] && [ -r "$sheet" ] && normalized=$(sh "$reader" "$sheet"); }; then normalized= diff --git a/plugins/pstack/skills/architect/SKILL.md b/plugins/pstack/skills/architect/SKILL.md index 7ecc59b5..b1dbc7f3 100644 --- a/plugins/pstack/skills/architect/SKILL.md +++ b/plugins/pstack/skills/architect/SKILL.md @@ -80,6 +80,6 @@ The caller's usage is written first and the type sketch derived from it. One fil ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - architect runners: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` diff --git a/plugins/pstack/skills/arena/SKILL.md b/plugins/pstack/skills/arena/SKILL.md index 7688f968..9359dd0f 100644 --- a/plugins/pstack/skills/arena/SKILL.md +++ b/plugins/pstack/skills/arena/SKILL.md @@ -49,7 +49,7 @@ One synthesized artifact, with a short synthesis note alongside naming the base ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - arena runners: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` - arena cross-judge pool: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` diff --git a/plugins/pstack/skills/how/SKILL.md b/plugins/pstack/skills/how/SKILL.md index 2d136a0d..7bae233f 100644 --- a/plugins/pstack/skills/how/SKILL.md +++ b/plugins/pstack/skills/how/SKILL.md @@ -37,7 +37,7 @@ The explanation uses the sections defined in `references/explainer-prompt.md`, d ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - how explorer: `claudeAgent/claude-opus-5-5 effort=xhigh` - how explainer: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/plugins/pstack/skills/interrogate/SKILL.md b/plugins/pstack/skills/interrogate/SKILL.md index 3fd88022..eded4a39 100644 --- a/plugins/pstack/skills/interrogate/SKILL.md +++ b/plugins/pstack/skills/interrogate/SKILL.md @@ -76,6 +76,6 @@ You are the lead reviewer, a pragmatic senior engineer, not a neutral aggregator ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - interrogate reviewers: `claudeAgent/claude-opus-5-5 effort=xhigh`, `codex/gpt-6.1-sol reasoningEffort=xhigh`, `grok/grok-4.7 reasoningEffort=xhigh` diff --git a/plugins/pstack/skills/poteto-help/SKILL.md b/plugins/pstack/skills/poteto-help/SKILL.md index 18ad5bac..88b5bed4 100644 --- a/plugins/pstack/skills/poteto-help/SKILL.md +++ b/plugins/pstack/skills/poteto-help/SKILL.md @@ -24,7 +24,7 @@ Infer the need from the message and the conversation. A named situation, such as Check the state that changes the answer, and mention it only when it does: -- No model sheet (`t3-pstack-models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user, so every role uses its default model. +- No model sheet (`~/.config/t3-pstack/models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user, so every role uses its default model. - No `verify-*` skill or other app harness in the project means agents have no scripted way to drive the app. Mention `/create-verification-skill` when the question is about proving a change works. When the model sheet is missing and it matters, ask whether the user wants to pick a model for each role and a reasoning budget now. It matters when the user is new, the question is about setup or cost, or the answer depends on which models run. Ask at most once per chat. If the need is also unclear, ask both questions together. Offer two choices: diff --git a/plugins/pstack/skills/poteto-mode/SKILL.md b/plugins/pstack/skills/poteto-mode/SKILL.md index d113c7d2..344539fd 100644 --- a/plugins/pstack/skills/poteto-mode/SKILL.md +++ b/plugins/pstack/skills/poteto-mode/SKILL.md @@ -159,7 +159,7 @@ A large or cross-cutting effort (a migration across many call sites, an ambitiou ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - feature, refactoring: `claudeAgent/claude-opus-5-5 effort=xhigh` - bug-fix: `claudeAgent/claude-fable-5-1 effort=xhigh` diff --git a/plugins/pstack/skills/reflect/SKILL.md b/plugins/pstack/skills/reflect/SKILL.md index 7c9bfbad..9f5d1a1e 100644 --- a/plugins/pstack/skills/reflect/SKILL.md +++ b/plugins/pstack/skills/reflect/SKILL.md @@ -78,7 +78,7 @@ Short list, no preamble: ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - reflect tooling: `claudeAgent/claude-opus-5-5 effort=xhigh` - reflect judgment, divergent, synthesizer: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/plugins/pstack/skills/setup-pstack/SKILL.md b/plugins/pstack/skills/setup-pstack/SKILL.md index 112cf432..548a458d 100644 --- a/plugins/pstack/skills/setup-pstack/SKILL.md +++ b/plugins/pstack/skills/setup-pstack/SKILL.md @@ -5,7 +5,7 @@ description: Configure which T3 provider and model pstack uses per role and at w # Setup pstack -Write the model sheet, `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). It sets pstack's T3 provider and model per role, and every pstack skill reads it before it dispatches. +Write the model sheet, `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine. It sets pstack's T3 provider and model per role, and every pstack skill reads it before it dispatches. ## Steps @@ -15,7 +15,7 @@ Call `orchestrator_capabilities`, or `acp-mcp-call orchestrator_capabilities '{} ### 2. Load current state -The default role-to-target mapping is the sheet shape in step 5 below. If the sheet already exists, read it and treat its `# budget` line and its role values as the current choices. Otherwise start from those defaults. A line whose role is not in step 5, such as `how critics`, is from a retired role. Drop it. Carry a `strongest judgment` value over to `hardest tasks`, its new name. Also drop any value that is not a T3 entry, such as a bare family name, a `claude-` slug, or an `@<level>` suffix, and a `default effort` line. +The default role-to-target mapping is the sheet shape in step 5 below. If the sheet already exists, read it and treat its `# budget` line and its role values as the current choices. If it does not, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), where releases before 2.0.0-rc.3 kept the sheet, the same way. Otherwise start from those defaults. A line whose role is not in step 5, such as `how critics`, is from a retired role. Drop it. Carry a `strongest judgment` value over to `hardest tasks`, its new name. Also drop any value that is not a T3 entry, such as a bare family name, a `claude-` slug, or an `@<level>` suffix, and a `default effort` line. ### 3. Budget, map, and confirm @@ -38,7 +38,7 @@ Write the candidate to a temp file. Call `orchestrator_capabilities` again and s ### 5. Write the sheet -Overwrite the sheet with the candidate, writing through any symlink at its path (resolve it with `realpath`). One sheet serves both hosts, so link the other host's sheet path to it with `ln -s`. If a different file is already there, run the checker on it and ask before you replace it. Overwrite the whole file so re-runs stay idempotent. On Codex, remove any pstack model rows an earlier setup pasted into `<codex-home>/AGENTS.md`. With every default kept, the sheet reads: +Overwrite the sheet with the candidate, creating its directory and writing through any symlink at its path (resolve it with `realpath`). If a different file is already there, run the checker on it and ask before you replace it. Overwrite the whole file so re-runs stay idempotent. Then replace `t3-pstack-models.md` in the host's config directory with a link to the sheet (`ln -sf`), so an older t3-pstack reads the same sheet. Step 2 already read what it held. On Codex, remove any pstack model rows an earlier setup pasted into `<codex-home>/AGENTS.md`. With every default kept, the sheet reads: ```markdown # pstack model configuration (T3). One line per role. Delete a line to fall back to the skill default. diff --git a/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs b/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs index 29ae575b..43587962 100644 --- a/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs +++ b/plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs @@ -67,6 +67,7 @@ export function parseSheet(markdown) { // role with its `tier`, and the `alias` a released sheet may still use), its // tier defaults, and an `orchestrator_capabilities` catalog. A role with no // line runs on its tier's default, so that default must dispatch here too. +// With no catalog, as in the installer's doctor, only the grammar is checked. export function checkSheet(sheet, { roles, defaults, catalog }) { const problems = []; const byRole = new Map(roles.flatMap((r) => [[r.role, r], ...(r.alias ? [[r.alias, r]] : [])])); @@ -128,6 +129,7 @@ export function checkSheet(sheet, { roles, defaults, catalog }) { } function checkTarget({ providerInstanceId, model, options }, catalog) { + if (!catalog) return []; const at = `${providerInstanceId}/${model}`; const provider = catalog.providers.find((p) => p.providerInstanceId === providerInstanceId); if (!provider) return [`${at}: no provider instance "${providerInstanceId}" in this T3`]; diff --git a/plugins/pstack/skills/swarm/SKILL.md b/plugins/pstack/skills/swarm/SKILL.md index c529eb05..134c74ec 100644 --- a/plugins/pstack/skills/swarm/SKILL.md +++ b/plugins/pstack/skills/swarm/SKILL.md @@ -47,6 +47,6 @@ Return one consolidated in-chat report with the table, issue one-liners, gaps or ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - swarm workers: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/plugins/pstack/skills/why/SKILL.md b/plugins/pstack/skills/why/SKILL.md index 56607873..242086bc 100644 --- a/plugins/pstack/skills/why/SKILL.md +++ b/plugins/pstack/skills/why/SKILL.md @@ -99,7 +99,7 @@ After Sources Consulted, if the user's `why` question is a precursor to changing ## Models -Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and `/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. +Role defaults on T3, stamped from `plugins/pstack/models.json`. A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` (`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's `<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from `orchestrator_capabilities`. `inherit-parent` or `auto` omits `target`. - why investigators: `claudeAgent/claude-opus-5-5 effort=xhigh` - why synthesizer: `claudeAgent/claude-opus-5-5 effort=xhigh` diff --git a/tests/codex-attacker.mjs b/tests/codex-attacker.mjs new file mode 100644 index 00000000..33c7981e --- /dev/null +++ b/tests/codex-attacker.mjs @@ -0,0 +1,42 @@ +// `codex`, with an attacker who writes files once, at one point of an +// app-server session, to reproduce a Codex home that changes while the +// installer trusts the hook. CODEX_ATTACK names a JSON file +// `{ "at": "<point>", "files": { "<path>": "<text>" } }`. A point is +// `before <method>`, when the installer's request reaches this proxy, or +// `after <method>`, before Codex's reply reaches the installer. The file is +// deleted when the attack fires. REAL_CODEX is the codex this runs. +import { spawn, spawnSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { createInterface } from "node:readline"; + +const args = process.argv.slice(2); +const real = process.env.REAL_CODEX; +if (args[0] !== "app-server") process.exit(spawnSync(real, args, { stdio: "inherit" }).status ?? 1); + +function attack(point) { + const plan = process.env.CODEX_ATTACK; + if (!plan || !existsSync(plan)) return; + const { at, files } = JSON.parse(readFileSync(plan, "utf8")); + if (at !== point) return; + rmSync(plan); + for (const [path, text] of Object.entries(files)) writeFileSync(path, text); +} + +const server = spawn(real, args, { stdio: ["pipe", "pipe", "inherit"] }); +const methods = new Map(); +createInterface({ input: process.stdin }) + .on("line", (line) => { + const message = line.trim() ? JSON.parse(line) : {}; + if (message.method && message.id !== undefined) { + methods.set(message.id, message.method); + attack(`before ${message.method}`); + } + server.stdin.write(`${line}\n`); + }) + .on("close", () => server.stdin.end()); +createInterface({ input: server.stdout }).on("line", (line) => { + const message = line.trim() ? JSON.parse(line) : {}; + if (!message.method && methods.has(message.id)) attack(`after ${methods.get(message.id)}`); + process.stdout.write(`${line}\n`); +}); +server.on("exit", (code) => process.exit(code ?? 1)); diff --git a/tests/generate.test.mjs b/tests/generate.test.mjs index 7df7a096..b92ee514 100644 --- a/tests/generate.test.mjs +++ b/tests/generate.test.mjs @@ -25,6 +25,7 @@ import { apply, assertChangesHeading, changes, + frozenHookFaults, loadModels, parseFrontmatter, plan, @@ -33,6 +34,7 @@ import { stampAgentPaths, stampVersion, } from "../tools/generate.mjs"; +import { hookDigest } from "../tools/plugin.mjs"; import { walk } from "../tools/validate-skills.mjs"; const repoRoot = fileURLToPath(new URL("..", import.meta.url)); @@ -313,10 +315,22 @@ describe("plan, changes, apply", () => { made.push(dir); return dir; }; + // A copy committed as its own origin/main, which the hooks check compares with. + const git = (dir, ...args) => { + const r = spawnSync("git", ["-C", dir, "-c", "user.name=t", "-c", "user.email=t@t", ...args], { encoding: "utf8" }); + expect(r.status, r.stderr).toBe(0); + }; const repoCopy = () => { const dir = scratch("pstack-generate-"); cpSync(repoRoot, dir, { recursive: true, filter: (src) => ![".git", "node_modules"].includes(basename(src)) }); symlinkSync(join(repoRoot, "node_modules"), join(dir, "node_modules")); + git(dir, "init", "-q"); + git(dir, "add", "-A"); + git(dir, "commit", "-qm", "main"); + const origin = scratch("pstack-origin-"); + git(origin, "init", "-q", "--bare"); + git(dir, "remote", "add", "origin", origin); + git(dir, "push", "-q", "origin", "HEAD:refs/heads/main"); return dir; }; const snapshot = (dir) => Object.fromEntries(walk(dir).map((path) => [path, readFileSync(path, "utf8")])); @@ -528,7 +542,10 @@ describe("plan, changes, apply", () => { const root = repoCopy(); const hooks = "plugins/pstack/hooks/codex-hooks.json"; writeFileSync(join(root, hooks), readFileSync(join(root, hooks), "utf8").replace("hooks/session-start.ps1", "hooks/gone.ps1")); - expect(problems(root)).toEqual([expect.stringContaining("hooks/gone.ps1 does not exist")]); + expect(problems(root)).toEqual([ + expect.stringContaining("hooks/gone.ps1 does not exist"), + expect.stringContaining(`${hooks} changed, which stops its hook`), + ]); const marketplace = ".agents/plugins/marketplace.json"; writeFileSync(join(root, marketplace), readFileSync(join(root, marketplace), "utf8").replace('"name": "t3-pstack",\n "source"', '"name": "other",\n "source"')); expect(problems(root)).toContainEqual(expect.stringContaining('plugin name "other" != Codex manifest name "t3-pstack"')); @@ -538,16 +555,122 @@ describe("plan, changes, apply", () => { const root = repoCopy(); const hooks = "plugins/pstack/hooks/hooks.json"; const text = readFileSync(join(root, hooks), "utf8"); + const changed = expect.stringContaining(`${hooks} changed, which stops its hook`); writeFileSync(join(root, hooks), text.replace("hooks/session-start.sh", "../../tools/generate.mjs")); - expect(problems(root)).toEqual([expect.stringContaining("../../tools/generate.mjs does not exist in the plugin")]); + expect(problems(root)).toEqual([expect.stringContaining("../../tools/generate.mjs does not exist in the plugin"), changed]); const outside = join(scratch("pstack-outside-"), "start.sh"); writeFileSync(outside, "#!/bin/sh\n", { mode: 0o755 }); symlinkSync(outside, join(root, "plugins/pstack/hooks/linked.sh")); writeFileSync(join(root, hooks), text.replace("hooks/session-start.sh", "hooks/linked.sh")); - expect(problems(root)).toEqual([expect.stringContaining("hooks/linked.sh does not exist in the plugin")]); + expect(problems(root)).toEqual([expect.stringContaining("hooks/linked.sh does not exist in the plugin"), changed]); + }); + + test("problems compares the hooks files with origin/main, so a repinned change cannot keep its old changedIn", () => { + const root = repoCopy(); + const hooks = "plugins/pstack/hooks/codex-hooks.json"; + const text = readFileSync(join(root, hooks), "utf8").replace("startup|resume|clear|compact", "startup|resume|compact"); + writeFileSync(join(root, hooks), text); + const pins = JSON.parse(readFileSync(join(root, "tools/frozen-hooks.json"), "utf8")); + pins[hooks].sha256 = hookDigest(text); + writeFileSync(join(root, "tools/frozen-hooks.json"), JSON.stringify(pins, null, 2) + "\n"); + const version = readFileSync(join(root, "VERSION"), "utf8").trim(); + expect(problems(root)).toEqual([ + `${hooks} differs from origin/main, which ships ${version}, so this change needs a VERSION newer than that, not ${version}`, + ]); + rmSync(join(root, ".git"), { recursive: true }); + expect(problems(root)).toEqual([expect.stringMatching(/^tools\/frozen-hooks.json: cannot fetch origin\/main to compare the hooks files with: fatal: not a git repository/)]); + }); + + test("problems fetches origin/main and compares with it, not with the merge-base, so a release main already shipped cannot be reused", () => { + const root = repoCopy(); + const hooks = "plugins/pstack/hooks/codex-hooks.json"; + const original = readFileSync(join(root, hooks), "utf8"); + // A release with `matcher` for the hooks file: VERSION, the hooks file, its pin, and a CHANGES entry. + const release = (version, matcher) => { + const text = original.replace("startup|resume|clear|compact", matcher); + writeFileSync(join(root, hooks), text); + const pins = JSON.parse(readFileSync(join(root, "tools/frozen-hooks.json"), "utf8")); + pins[hooks] = { sha256: hookDigest(text), changedIn: version }; + writeFileSync(join(root, "tools/frozen-hooks.json"), JSON.stringify(pins, null, 2) + "\n"); + writeFileSync(join(root, "VERSION"), `${version}\n`); + writeFileSync(join(root, "CHANGES.md"), `# CHANGES\n\n## ${version} - Next\n\nRerun the installer.\n\n` + readFileSync(join(root, "CHANGES.md"), "utf8").replace(/^# CHANGES\n\n/, "")); + }; + writeFileSync(join(root, "VERSION"), "9.0.0-rc.2\n"); + git(root, "commit", "-qam", "9.0.0-rc.2"); + git(root, "push", "-q", "origin", "HEAD:refs/heads/main"); + git(root, "checkout", "-qb", "branch"); + // Main ships 9.0.0-rc.3 with hook A after the branch is cut, and the + // branch's own origin/main still points at 9.0.0-rc.2. + release("9.0.0-rc.3", "startup|resume"); + git(root, "commit", "-qam", "main ships 9.0.0-rc.3 with hook A"); + git(root, "push", "-q", "origin", "HEAD:refs/heads/main"); + git(root, "reset", "-q", "--hard", "HEAD~1"); + git(root, "update-ref", "refs/remotes/origin/main", "HEAD"); + + release("9.0.0-rc.3", "startup|clear"); + expect(problems(root)).toEqual([ + `${hooks} differs from origin/main, which ships 9.0.0-rc.3, so this change needs a VERSION newer than that, not 9.0.0-rc.3`, + ]); + git(root, "checkout", "-q", "."); + release("9.0.0-rc.1", "startup|clear"); + expect(problems(root)).toEqual([ + `${hooks} differs from origin/main, which ships 9.0.0-rc.3, so this change needs a VERSION newer than that, not 9.0.0-rc.1`, + ]); + git(root, "checkout", "-q", "."); + release("9.0.0-rc.4", "startup|clear"); + expect(problems(root)).toEqual([]); }); test("problems reports a root with no plugin directory, down to the last check, and does not throw", () => { expect(problems(scratch("pstack-empty-"))).toContainEqual(expect.stringContaining("plugins/pstack/hooks/hooks.json")); }); }); + +describe("frozen hook entries", () => { + const path = "plugins/pstack/hooks/codex-hooks.json"; + const hooks = '{"hooks":{"SessionStart":[{"matcher":"startup","hooks":[{"type":"command","command":"x"}]}]}}'; + const changed = hooks.replace("startup", "startup|resume"); + const changelog = (body) => `# CHANGES\n\n## 2.1.0 - Next\n\n${body}\n\n## 2.0.0 - Before\n\nRerun the installer.\n`; + const main = { version: "2.0.0", files: { [path]: hooks } }; + const faults = ({ pins, file = changed, version = "2.1.0", body = "Rerun the installer.", shipped = main }) => + frozenHookFaults({ pins, files: { [path]: file }, version, changelog: changelog(body), shipped }); + + test("pass while each hooks file matches its pin and main, whatever its formatting", () => { + const pins = { [path]: { sha256: hookDigest(hooks), changedIn: "2.0.0" } }; + expect(faults({ pins, file: JSON.stringify(JSON.parse(hooks), null, 2), body: "Nothing." })).toEqual([]); + }); + + test("fail on a change until it is pinned to the release that announces it", () => { + const pins = { [path]: { sha256: hookDigest(hooks), changedIn: "2.0.0" } }; + expect(faults({ pins, body: "Nothing." })).toEqual([ + `${path} changed, which stops its hook in every Codex home until the installer reruns. ` + + `Pin "sha256": "${hookDigest(changed)}" and "changedIn": "2.1.0" in tools/frozen-hooks.json, ` + + "and tell users to rerun the installer in CHANGES.md's 2.1.0 entry.", + ]); + const repinned = { [path]: { sha256: hookDigest(changed), changedIn: "2.1.0" } }; + expect(faults({ pins: repinned, body: "Nothing." })).toEqual([ + `${path} changes in 2.1.0, but CHANGES.md's 2.1.0 entry does not tell users to rerun the installer`, + ]); + expect(faults({ pins: repinned })).toEqual([]); + }); + + test("fail on a repinned change that keeps main's changedIn or main's VERSION", () => { + const stale = { [path]: { sha256: hookDigest(changed), changedIn: "2.0.0" } }; + const stalePin = `${path} differs from origin/main, so its pin's "changedIn" must be 2.1.0, not 2.0.0`; + expect(faults({ pins: stale })).toEqual([stalePin]); + expect(faults({ pins: stale, shipped: { version: "2.0.0", files: {} } })).toEqual([stalePin]); + expect(faults({ pins: stale, version: "2.0.0" })).toEqual([ + `${path} differs from origin/main, which ships 2.0.0, so this change needs a VERSION newer than that, not 2.0.0`, + ]); + expect(faults({ pins: { [path]: { sha256: hookDigest(changed), changedIn: "2.0.0-rc.9" } }, version: "2.0.0-rc.9" })).toEqual([ + `${path} differs from origin/main, which ships 2.0.0, so this change needs a VERSION newer than that, not 2.0.0-rc.9`, + ]); + }); + + test("fail on a hooks file with no pin and a pin with no file", () => { + expect(frozenHookFaults({ pins: { "a.json": { sha256: "x" } }, files: { [path]: hooks }, version: "2.1.0", changelog: "", shipped: main })).toEqual([ + `${path} has no pin in tools/frozen-hooks.json`, + "tools/frozen-hooks.json pins a.json, which no manifest names", + ]); + }); +}); diff --git a/tests/install-cli.test.mjs b/tests/install-cli.test.mjs new file mode 100644 index 00000000..f6e1bb7c --- /dev/null +++ b/tests/install-cli.test.mjs @@ -0,0 +1,72 @@ +// The installer finds each CLI by path or on PATH, and starts it with every +// argument intact. On Windows, npm installs claude and codex as .cmd shims, +// which Node starts only through cmd.exe. CI's Windows job runs this file. +// The installer runs on Node, so the calls go through a Node child. +import { afterAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { chmodSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const common = pathToFileURL(fileURLToPath(new URL("../tools/install/common.mjs", import.meta.url))).href; +const windows = process.platform === "win32"; +const dir = realpathSync(mkdtempSync(join(tmpdir(), "t3-pstack-cli-"))); +afterAll(() => rmSync(dir, { recursive: true, force: true })); + +// What cmd.exe would split, expand, or run as a second command. +const ARGS = ["plugin", "C:\\Program Files (x86)\\t3 pstack\\", 'say "hi"', "a&b|c", "100%", "%PATH%", "^caret", "!bang!", "<in>out", "x;y,z", ""]; + +writeFileSync(join(dir, "argv.js"), "console.log(JSON.stringify(process.argv.slice(2)));\n"); +if (windows) { + // npm's cmd-shim, as it installs a package's bin, beside the sh script npm + // also writes, which Windows cannot run. + writeFileSync(join(dir, "fake-cli"), '#!/bin/sh\nexec node "$(dirname "$0")/argv.js" "$@"\n'); + writeFileSync( + join(dir, "fake-cli.cmd"), + [ + "@ECHO off", + "GOTO start", + ":find_dp0", + "SET dp0=%~dp0", + "EXIT /b", + ":start", + "SETLOCAL", + "CALL :find_dp0", + 'SET "_prog=node"', + 'endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\\argv.js" %*', + "", + ].join("\r\n"), + ); +} else { + writeFileSync(join(dir, "fake-cli"), `#!/bin/sh\nexec node "${join(dir, "argv.js")}" "$@"\n`); + chmodSync(join(dir, "fake-cli"), 0o755); +} + +// Windows spells it Path, and a second key in the child's environment would +// not replace it. +const PATH = Object.keys(process.env).find((key) => key.toUpperCase() === "PATH") ?? "PATH"; + +function installer(bin, args) { + const script = + `import { hasCli, runCli } from ${JSON.stringify(common)};\n` + + "const [bin, args] = [process.argv[1], JSON.parse(process.argv[2])];\n" + + "console.log(JSON.stringify({ found: hasCli(bin), argv: JSON.parse(runCli(bin, args)) }));\n"; + const r = spawnSync("node", ["--input-type=module", "-e", script, bin, JSON.stringify(args)], { + env: { ...process.env, [PATH]: `${dir}${delimiter}${process.env[PATH]}` }, + encoding: "utf8", + }); + expect(r.status, r.stderr).toBe(0); + return JSON.parse(r.stdout); +} + +describe("the installer's CLI launch", () => { + test("finds a CLI on PATH and passes every argument through unchanged", () => { + expect(installer("fake-cli", ARGS)).toEqual({ found: true, argv: ARGS }); + }); + + test("finds a CLI named by its full path", () => { + const path = join(dir, windows ? "fake-cli.cmd" : "fake-cli"); + expect(installer(path, ["plugin", "list"])).toEqual({ found: true, argv: ["plugin", "list"] }); + }); +}); diff --git a/tests/install-e2e.test.mjs b/tests/install-e2e.test.mjs new file mode 100644 index 00000000..dc1a5d00 --- /dev/null +++ b/tests/install-e2e.test.mjs @@ -0,0 +1,486 @@ +// The installer end to end, with the real claude and codex CLIs, in a +// throwaway HOME that T3's settings file describes. Opt in with +// T3_PSTACK_E2E=1; it needs both CLIs and takes about a minute. This checkout +// is the marketplace source. The Codex probe turn has no credentials, so it +// fails at the model call, after the session-start hook has run. +import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { AppServer } from "../tools/install/codex.mjs"; +import { resolveCli } from "../tools/install/common.mjs"; +import { hookDigest } from "../tools/plugin.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const version = JSON.parse(readFileSync(join(repoRoot, "plugins/pstack/.claude-plugin/plugin.json"), "utf8")).version; +const ROUTING = "You have t3-pstack, pstack for T3 Code."; +const SHEET = "# budget: large (xhigh)\nbug-fix: inherit-parent\nsession hook: on\n"; + +let root; +let home; +const saved = {}; +const ISOLATED = ["HOME", "CODEX_HOME", "CLAUDE_CONFIG_DIR", "T3CODE_HOME", "XDG_CONFIG_HOME", "GIT_CONFIG_GLOBAL"]; + +function sh(bin, args, env = {}) { + const r = spawnSync(bin, args, { cwd: root, env: { ...process.env, ...env }, encoding: "utf8" }); + expect(r.status, `${bin} ${args.join(" ")}\n${r.stdout}\n${r.stderr}`).toBe(0); + 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"], { + cwd: root, + env: process.env, + encoding: "utf8", + }); + return { status: r.status, report: JSON.parse(r.stdout) }; +} + +const row = (report, dir) => report.homes.find((h) => h.dir === join(home, dir)); +const failing = (checks) => checks.filter((c) => !c.ok && !c.warn).map((c) => `${c.name}: ${c.detail}`); +const HOOK = "t3-pstack@t3-pstack:hooks/codex-hooks.json:session_start:0:0"; + +// The plugin as a marketplace of its own, outside this checkout. +function pluginCopy(dir) { + for (const path of [".claude-plugin", ".agents", "plugins", "tools/frozen-hooks.json"]) { + cpSync(join(repoRoot, path), join(dir, path), { recursive: true, filter: (src) => !src.includes("node_modules") }); + } + return dir; +} +const edit = (path, from, to) => writeFileSync(path, readFileSync(path, "utf8").replace(from, to)); + +async function until(done, ms) { + for (const deadline = Date.now() + ms; Date.now() < deadline; await new Promise((r) => setTimeout(r, 200))) { + if (done()) return true; + } + return false; +} + +// A T3-shaped Codex session: thread/start, then one turn. SessionStart runs +// at the first turn, before the model call. +async function probe(codexHome) { + const server = await AppServer.start({ dir: codexHome, bin: "codex" }); + try { + const { thread } = await server.request("thread/start", { cwd: root, 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") + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line)); + const developer = rollout + .filter((item) => item.payload?.type === "message" && item.payload.role === "developer") + .flatMap((item) => item.payload.content.map((part) => part.text ?? "")); + return { + hookEvents: server.notifications.filter((m) => m.method.startsWith("hook/")).map((m) => m.method), + routedMessages: developer.filter((text) => text.includes(ROUTING)).length, + rollout: thread.path, + }; + } finally { + server.close(); + } +} + +// Every config file in every home, and the sheet, by content. +function snapshot() { + const out = {}; + const walk = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if (!["cache", "marketplaces", ".tmp", "sessions", "log", "skills", "shell_snapshots"].includes(entry.name)) walk(path); + } else if (/\.(json|toml|md)$/.test(entry.name)) { + out[relative(home, path)] = entry.isSymbolicLink() + ? `-> ${readlinkSync(path)}` + : createHash("sha256").update(readFileSync(path)).digest("hex"); + } + } + }; + walk(home); + return out; +} + +const enabled = process.env.T3_PSTACK_E2E === "1"; + +// A fresh HOME per suite, with T3's settings listing `providerInstances`. +function isolate(providerInstances) { + beforeAll(() => { + root = realpathSync(mkdtempSync(join(tmpdir(), "t3-pstack-e2e-"))); + home = join(root, "home"); + for (const name of ISOLATED) saved[name] = process.env[name]; + for (const name of ISOLATED) delete process.env[name]; + Object.assign(process.env, { HOME: home, GIT_CONFIG_GLOBAL: "/dev/null" }); + mkdirSync(join(home, ".t3/userdata"), { recursive: true }); + writeFileSync(join(home, ".t3/userdata/settings.json"), JSON.stringify({ providerInstances })); + }); + + afterAll(() => { + for (const name of ISOLATED) { + if (saved[name] === undefined) delete process.env[name]; + else process.env[name] = saved[name]; + } + rmSync(root, { recursive: true, force: true }); + }); +} + +describe.skipIf(!enabled)("the installer, end to end", () => { + isolate({ + claudeAgent: { driver: "claudeAgent", enabled: true, config: { binaryPath: "claude", homePath: "" } }, + claude_pool: { driver: "claudeAgent", enabled: true, config: { homePath: "~/.claude-pool" } }, + codex: { driver: "codex", enabled: true, config: { binaryPath: "codex", homePath: "" } }, + codex_work: { driver: "codex", enabled: false, config: { homePath: "~/.codex-work" } }, + }); + + test("before: a hand-installed Codex home never runs the hook, and doctor says so", async () => { + // The pre-installer README way, and the released per-home sheet. + const codexHome = join(home, ".codex"); + mkdirSync(codexHome, { recursive: true }); + sh("codex", ["plugin", "marketplace", "add", repoRoot], { CODEX_HOME: codexHome }); + sh("codex", ["plugin", "add", "t3-pstack@t3-pstack"], { CODEX_HOME: codexHome }); + mkdirSync(join(home, ".claude"), { recursive: true }); + writeFileSync(join(home, ".claude/t3-pstack-models.md"), SHEET); + symlinkSync(join(home, ".claude/t3-pstack-models.md"), join(codexHome, "t3-pstack-models.md")); + + // A second Claude home with a stale project-scope record for the home folder. + const pool = join(home, ".claude-pool"); + mkdirSync(pool, { recursive: true }); + sh("claude", ["plugin", "marketplace", "add", repoRoot], { CLAUDE_CONFIG_DIR: pool }); + sh("claude", ["plugin", "install", "t3-pstack@t3-pstack", "--scope", "user"], { CLAUDE_CONFIG_DIR: pool }); + const recordsPath = join(pool, "plugins/installed_plugins.json"); + const records = JSON.parse(readFileSync(recordsPath, "utf8")); + records.plugins["t3-pstack@t3-pstack"].push({ + scope: "project", + projectPath: home, + installPath: join(pool, "plugins/cache/t3-pstack/t3-pstack/1.0.11"), + version: "1.0.11", + installedAt: "2026-10-13T04:43:26.759Z", + lastUpdated: "2026-10-13T04:43:26.759Z", + }); + writeFileSync(recordsPath, JSON.stringify(records, null, 2) + "\n"); + + const before = await probe(codexHome); + console.log("before:", JSON.stringify(before)); + expect(before.hookEvents).toEqual([]); + expect(before.routedMessages).toBe(0); + + const { status, report } = installer("doctor"); + expect(status).toBe(1); + expect(failing(row(report, ".codex").checks)).toEqual([ + `session-start hook trusted: ${HOOK} untrusted`, + ]); + expect(failing(row(report, ".claude-pool").checks)).toEqual([ + `other-scope records: project ${home} at 1.0.11`, + ]); + expect(failing(row(report, ".codex-work").checks)).toEqual(["installed: the home does not exist"]); + expect(failing(report.sheet.checks)).toEqual([ + `machine sheet: missing; ${join(home, ".claude/t3-pstack-models.md")}, ${join(home, ".codex/t3-pstack-models.md")} not moved yet`, + ]); + }, 300_000); + + test("install: every home converges, the hook is trusted, and its routing text reaches the rollout", async () => { + const { status, report } = installer("install"); + console.log("install:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir, steps })).concat(report.sheet.steps))); + expect(failing([...report.homes.flatMap((h) => h.checks), ...report.sheet.checks])).toEqual([]); + expect(status).toBe(0); + expect(row(report, ".codex").steps).toEqual([ + `trusted hook ${HOOK} (was untrusted)`, + ]); + expect(row(report, ".codex-work").steps).toEqual([ + `added the t3-pstack marketplace from ${repoRoot.replace(/\/$/, "")}`, + `installed ${version}`, + `trusted hook ${HOOK} (was untrusted)`, + ]); + const pool = row(report, ".claude-pool").steps; + expect(pool[0]).toBe(`moved the project-scope record for ${home} from 1.0.11 to ${version}`); + expect(pool[1]).toStartWith("backed up plugins/installed_plugins.json to "); + expect(report.sheet.steps).toEqual([ + `moved the sheet from ${join(home, ".claude/t3-pstack-models.md")}`, + `linked ${join(home, ".claude/t3-pstack-models.md")} to it`, + `linked ${join(home, ".claude-pool/t3-pstack-models.md")} to it`, + `linked ${join(home, ".codex-work/t3-pstack-models.md")} to it`, + ]); + expect(readFileSync(join(home, ".config/t3-pstack/models.md"), "utf8")).toBe(SHEET); + + const after = await probe(join(home, ".codex")); + console.log("after:", JSON.stringify(after)); + expect(after.hookEvents).toEqual(["hook/started", "hook/completed"]); + expect(after.routedMessages).toBe(1); + + // Claude Code needs no trust; the same routing text arrives through its hook. + const claude = spawnSync("claude", ["-p", "ping", "--output-format", "stream-json", "--verbose"], { + cwd: root, + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, ".claude-pool"), + ANTHROPIC_API_KEY: "sk-ant-invalid", + ANTHROPIC_BASE_URL: "http://127.0.0.1:9", + }, + encoding: "utf8", + timeout: 120_000, + }); + const hookResponse = claude.stdout + .split("\n") + .filter((line) => line.includes('"hook_response"')) + .map((line) => JSON.parse(line)) + .find((event) => event.hook_event === "SessionStart"); + expect(hookResponse?.output ?? hookResponse?.stdout ?? "", "Claude Code's SessionStart hook_response").toContain(ROUTING); + console.log("claude:", JSON.stringify({ hook: hookResponse.hook_name, routed: true })); + }, 300_000); + + test("a second install changes nothing, and doctor passes", () => { + const before = snapshot(); + const { status, report } = installer("update"); + expect(status).toBe(0); + expect([...report.homes.flatMap((h) => h.steps), ...report.sheet.steps]).toEqual([]); + expect(snapshot()).toEqual(before); + console.log(`rerun: ${Object.keys(before).length} config files unchanged`); + expect(installer("doctor").status).toBe(0); + expect(existsSync(join(home, ".codex-work/config.toml"))).toBe(true); + }, 300_000); + + test("install reinstalls a plugin whose files are gone, and enables a disabled one", () => { + const codexCache = join(home, ".codex/plugins/cache/t3-pstack/t3-pstack", version); + rmSync(join(codexCache, "skills/how"), { recursive: true }); + const poolRecord = JSON.parse(readFileSync(join(home, ".claude-pool/plugins/installed_plugins.json"), "utf8")).plugins["t3-pstack@t3-pstack"][0]; + rmSync(poolRecord.installPath, { recursive: true }); + edit(join(home, ".codex-work/config.toml"), /(\[plugins\."t3-pstack@t3-pstack"\]\s*enabled = )true/, "$1false"); + + const doctor = installer("doctor").report; + expect(failing(row(doctor, ".codex").checks)).toEqual([`installed: its files in ${codexCache} are missing`]); + expect(failing(row(doctor, ".claude-pool").checks)).toEqual([ + `installed: ${version}, but its files at ${poolRecord.installPath} are missing`, + ]); + expect(failing(row(doctor, ".codex-work").checks)).toEqual(["enabled: no", "session-start hook trusted: no t3-pstack hook listed"]); + + const { status, report } = installer("install"); + expect(row(report, ".codex").steps).toEqual([`reinstalled ${version}, whose files were missing`]); + expect(row(report, ".claude-pool").steps).toEqual([`reinstalled ${version}, whose files were missing`]); + expect(row(report, ".codex-work").steps).toEqual(["enabled it"]); + expect(status).toBe(0); + }, 300_000); + + test("doctor flags a plain copy of a plugin skill, not a user's own skill that shares a name", () => { + mkdirSync(join(home, ".agents/skills/how"), { recursive: true }); + mkdirSync(join(home, ".agents/skills/why"), { recursive: true }); + writeFileSync(join(home, ".agents/skills/how/SKILL.md"), "---\nname: how\ndescription: My own notes on how things work.\n---\n"); + cpSync(join(repoRoot, "plugins/pstack/skills/why/SKILL.md"), join(home, ".agents/skills/why/SKILL.md")); + const { status, report } = installer("doctor"); + expect(failing(row(report, ".codex").checks)).toEqual([`no plain copies of plugin skills: ${join(home, ".agents/skills/why/SKILL.md")}`]); + expect(status).toBe(1); + rmSync(join(home, ".agents/skills/why"), { recursive: true }); + expect(installer("doctor").status).toBe(0); + }, 300_000); +}); + +describe.skipIf(!enabled)("the installer, on a t3-pstack marketplace from another source", () => { + isolate({ + claudeAgent: { driver: "claudeAgent", enabled: true, config: { binaryPath: "claude", homePath: "" } }, + codex: { driver: "codex", enabled: true, config: { binaryPath: "codex", homePath: "" } }, + codex_dev: { driver: "codex", enabled: true, config: { homePath: "~/.codex-dev" } }, + }); + + test("installs nothing from it, trusts none of its hooks, and doctor names the source", async () => { + // A marketplace that calls itself t3-pstack, with a hook that leaves a mark. + const counterfeit = pluginCopy(join(root, "counterfeit")); + const marked = join(root, "hook-ran"); + edit(join(counterfeit, "plugins/pstack/hooks/codex-hooks.json"), /"command": "[^\n]*",\n/, `"command": "touch ${marked}",\n`); + for (const [bin, dir, variable] of [["codex", ".codex", "CODEX_HOME"], ["claude", ".claude", "CLAUDE_CONFIG_DIR"]]) { + mkdirSync(join(home, dir), { recursive: true }); + sh(bin, ["plugin", "marketplace", "add", counterfeit], { [variable]: join(home, dir) }); + } + sh("codex", ["plugin", "add", "t3-pstack@t3-pstack"], { CODEX_HOME: join(home, ".codex") }); + + const { status, report } = installer("install"); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(row(report, ".claude").steps).toEqual([]); + const foreign = `marketplace: ${counterfeit}, which is neither chhoumann/t3-pstack nor --source`; + expect(failing(row(report, ".codex").checks)).toEqual([ + foreign, + `session-start hook trusted: ${HOOK} untrusted, not from the hooks/codex-hooks.json this release pins`, + ]); + expect(failing(row(report, ".claude").checks)).toEqual([foreign, "installed: not installed", "enabled: no"]); + expect(row(report, ".codex-dev").steps).toEqual([ + `added the t3-pstack marketplace from ${repoRoot.replace(/\/$/, "")}`, + `installed ${version}`, + `trusted hook ${HOOK} (was untrusted)`, + ]); + + const after = await probe(join(home, ".codex")); + expect(after.hookEvents).toEqual([]); + expect(existsSync(marked)).toBe(false); + }, 300_000); + + test("a later run without that --source reuses none of its marketplaces", () => { + const before = snapshot(); + const { status, report } = installer("install", null); + expect(status).toBe(1); + expect(report.homes.flatMap((h) => h.steps)).toEqual([]); + expect(failing(row(report, ".codex-dev").checks)).toEqual([ + `marketplace: ${repoRoot.replace(/\/$/, "")}, which is neither chhoumann/t3-pstack nor --source`, + ]); + expect(snapshot()).toEqual(before); + }, 300_000); +}); + +describe.skipIf(!enabled)("the installer, on a release that changes the hook entry", () => { + isolate({}); + + test("updates both homes, and trusts the changed hook once the release pins it", async () => { + const source = pluginCopy(join(root, "source")); + expect(installer("install", source).status).toBe(0); + + const next = "9.9.9"; + for (const file of [".claude-plugin/marketplace.json", "plugins/pstack/.claude-plugin/plugin.json", "plugins/pstack/.codex-plugin/plugin.json"]) { + const path = join(source, file); + writeFileSync(path, readFileSync(path, "utf8").replace(`"version": "${version}"`, `"version": "${next}"`)); + } + const hooks = join(source, "plugins/pstack/hooks/codex-hooks.json"); + edit(hooks, "startup|resume|clear|compact", "startup|resume|compact|clear"); + + const unpinned = installer("update", source); + expect(unpinned.status).toBe(1); + expect(row(unpinned.report, ".claude").steps).toEqual([`updated ${version} to ${next}`]); + expect(row(unpinned.report, ".codex").steps).toEqual([`updated ${version} to ${next}`]); + expect(failing(row(unpinned.report, ".codex").checks)).toEqual([ + `session-start hook trusted: ${HOOK} modified, not from the hooks/codex-hooks.json this release pins`, + ]); + + const pins = join(source, "tools/frozen-hooks.json"); + const pinned = JSON.parse(readFileSync(pins, "utf8")); + pinned["plugins/pstack/hooks/codex-hooks.json"] = { sha256: hookDigest(readFileSync(hooks, "utf8")), changedIn: next }; + writeFileSync(pins, JSON.stringify(pinned, null, 2) + "\n"); + const { status, report } = installer("update", source); + console.log("release:", JSON.stringify(report.homes.map(({ dir, steps }) => ({ dir: relative(home, dir), steps })))); + expect(failing([...report.homes.flatMap((h) => h.checks), ...report.sheet.checks])).toEqual([]); + expect(status).toBe(0); + expect(row(report, ".claude").steps).toEqual([]); + expect(row(report, ".codex").steps).toEqual([`trusted hook ${HOOK} (was modified)`]); + const after = await probe(join(home, ".codex")); + expect(after.hookEvents).toEqual(["hook/started", "hook/completed"]); + expect(after.routedMessages).toBe(1); + }, 300_000); +}); + +describe.skipIf(!enabled)("the installer, on a Codex home that changes while it trusts the hook", () => { + isolate({}); + const path = process.env.PATH; + const hooksFile = () => join(home, ".codex/plugins/cache/t3-pstack/t3-pstack", version, "hooks/codex-hooks.json"); + const configFile = () => join(home, ".codex/config.toml"); + const attack = (at, files) => writeFileSync(process.env.CODEX_ATTACK, JSON.stringify({ at, files })); + let genuine; + let config; + let malicious; + let marked; + + async function withCodex(use) { + const server = await AppServer.start({ dir: join(home, ".codex"), bin: "codex" }); + try { + return await use(server); + } finally { + server.close(); + } + } + const trustState = () => withCodex(async (server) => (await server.request("config/read", {})).config.hooks?.state ?? null); + + beforeAll(() => { + const bin = join(root, "bin"); + mkdirSync(bin); + writeFileSync(join(bin, "codex"), `#!/bin/sh\nexec node "${join(repoRoot, "tests/codex-attacker.mjs")}" "$@"\n`, { mode: 0o755 }); + process.env.REAL_CODEX = resolveCli("codex"); + process.env.CODEX_ATTACK = join(root, "attack.json"); + process.env.PATH = `${bin}${delimiter}${path}`; + expect(installer("install").status).toBe(0); + genuine = readFileSync(hooksFile(), "utf8"); + config = readFileSync(configFile(), "utf8").replace(/\n\[hooks\.state[^]*$/, "\n"); + marked = join(root, "hook-ran"); + malicious = genuine.replace(/"command": "[^\n]*",\n/, `"command": "touch ${marked}",\n`); + }); + + // Each test starts from the installed plugin with its hook untrusted. + beforeEach(() => { + rmSync(process.env.CODEX_ATTACK, { force: true }); + rmSync(hooksFile()); + writeFileSync(hooksFile(), genuine); + writeFileSync(configFile(), config); + }); + + afterAll(() => { + process.env.PATH = path; + delete process.env.REAL_CODEX; + delete process.env.CODEX_ATTACK; + }); + + test("trusts nothing from a hooks file that is a symlink, even to the pinned text", async () => { + const copy = join(root, "codex-hooks.json"); + writeFileSync(copy, genuine); + rmSync(hooksFile()); + symlinkSync(copy, hooksFile()); + const { status, report } = installer("install"); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(failing(row(report, ".codex").checks)).toEqual([ + `session-start hook trusted: ${HOOK} untrusted, not from the hooks/codex-hooks.json this release pins`, + ]); + expect(await trustState()).toBe(null); + }, 300_000); + + test("trusts nothing when the hooks file Codex listed is swapped for the pinned one before the installer reads it", async () => { + writeFileSync(hooksFile(), malicious); + attack("after hooks/list", { [hooksFile()]: genuine }); + const { status, report } = installer("install"); + expect(existsSync(process.env.CODEX_ATTACK)).toBe(false); + expect(status).toBe(1); + expect(row(report, ".codex").steps).toEqual([]); + expect(failing(row(report, ".codex").checks)).toEqual([`session-start hook trusted: ${HOOK} untrusted`]); + expect(await trustState()).toBe(null); + + // The attacker's entry, back for the next session, does not run. + writeFileSync(hooksFile(), malicious); + const after = await probe(join(home, ".codex")); + expect(after.hookEvents).toEqual([]); + expect(existsSync(marked)).toBe(false); + }, 300_000); + + test("takes the trust back when the hooks file changes between the check and the trust write", async () => { + attack("before config/value/write", { [hooksFile()]: malicious }); + const { status, report } = installer("install"); + expect(existsSync(process.env.CODEX_ATTACK)).toBe(false); + expect(status).toBe(1); + expect(failing(row(report, ".codex").checks)).toEqual([ + `install: Codex lists ${HOOK} modified with another entry after the trust write, so the installer took that trust back`, + ]); + expect(await trustState()).toBe(null); + }, 300_000); + + test("trusts nothing when config.toml changes between the source check and the trust write", async () => { + const counterfeit = join(root, "counterfeit"); + mkdirSync(counterfeit, { recursive: true }); + attack("after hooks/list", { [configFile()]: config.replace(/^source = .*$/m, `source = ${JSON.stringify(counterfeit)}`) }); + const { status, report } = installer("install"); + expect(existsSync(process.env.CODEX_ATTACK)).toBe(false); + expect(status).toBe(1); + expect(failing(row(report, ".codex").checks)).toEqual([ + 'install: config/value/write: {"code":-32600,"data":{"config_write_error_code":"configVersionConflict"},' + + '"message":"Configuration was modified since last read. Fetch latest version and retry."}', + ]); + expect(await trustState()).toBe(null); + }, 300_000); +}); diff --git a/tests/install.test.mjs b/tests/install.test.mjs new file mode 100644 index 00000000..98c4264c --- /dev/null +++ b/tests/install.test.mjs @@ -0,0 +1,510 @@ +// The installer's file-level behavior, on constructed homes: which homes it +// finds, how it repairs a stale Claude Code record, how it moves the model +// sheet, and what doctor reports. tests/install-e2e.test.mjs drives the real +// CLIs. +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + readlinkSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +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 { parseSource, writeAtomic } from "../tools/install/common.mjs"; +import { discoverHomes } from "../tools/install/homes.mjs"; +import { doctorSheet, moveSheet } from "../tools/install/sheet.mjs"; + +const repoRoot = fileURLToPath(new URL("..", import.meta.url)); +const { roles, tiers } = JSON.parse(readFileSync(join(repoRoot, "plugins/pstack/models.json"), "utf8")); +const FIX = "rerun `npx -y github:chhoumann/t3-pstack install`"; + +const scratch = []; +function tmp() { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "t3-pstack-install-"))); + scratch.push(dir); + return dir; +} +afterEach(() => { + while (scratch.length) rmSync(scratch.pop(), { recursive: true, force: true }); +}); +const writeJson = (path, value) => { + mkdirSync(join(path, ".."), { recursive: true }); + writeFileSync(path, JSON.stringify(value, null, 2) + "\n"); +}; +const mode = (path) => statSync(path).mode & 0o777; + +// The plugin's own skills, and a doctor context that trusts the GitHub repo. +const SKILL = (name) => readFileSync(join(repoRoot, "plugins/pstack/skills", name, "SKILL.md"), "utf8"); +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() { + const dir = tmp(); + const bin = join(dir, "claude"); + writeFileSync(bin, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${dir}/calls"\n`); + chmodSync(bin, 0o755); + return { bin, calls: () => (existsSync(join(dir, "calls")) ? readFileSync(join(dir, "calls"), "utf8").trim().split("\n") : []) }; +} + +describe("discoverHomes", () => { + test("resolves each home as T3 does and merges the sources that name it", () => { + const t3 = tmp(); + writeJson(join(t3, "userdata/settings.json"), { + providerInstances: { + claudeAgent: { driver: "claudeAgent", enabled: true, config: { binaryPath: "claude", homePath: "" } }, + claude_pool: { driver: "claudeAgent", config: { homePath: "~/.claude_pool", binaryPath: "/opt/bin/claude" } }, + claude_env: { driver: "claudeAgent", environment: [{ name: "CLAUDE_CONFIG_DIR", value: "/opt/claude-env" }] }, + codex: { driver: "codex", config: { homePath: "", shadowHomePath: "~/.codex-acct2" } }, + codex_pool: { driver: "codex", enabled: false, config: { homePath: "/home/u/.codex-pool" } }, + codex_env: { driver: "codex", config: { enabled: false }, environment: [{ name: "CODEX_HOME", value: "/opt/codex-env" }] }, + grok: { driver: "grok", enabled: true }, + }, + }); + const found = discoverHomes({ + env: { T3CODE_HOME: t3, CODEX_HOME: "/home/u/.codex-acct2", CLAUDE_CONFIG_DIR: "/srv/claude" }, + home: "/home/u", + claudeHomes: ["~/.claude_pool"], + codexHomes: ["/data/codex"], + }); + expect(found).toEqual({ + homes: [ + { harness: "claude", dir: "/home/u/.claude", from: ["default", "T3 instance claudeAgent"], bin: "claude" }, + { harness: "codex", dir: "/home/u/.codex", from: ["default", "T3 instance codex"], bin: "codex" }, + { harness: "claude", dir: "/home/u/.claude_pool", from: ["T3 instance claude_pool", "--claude-home"], bin: "/opt/bin/claude" }, + { harness: "claude", dir: "/opt/claude-env", from: ["T3 instance claude_env"], bin: "claude" }, + { harness: "codex", dir: "/home/u/.codex-pool", from: ["T3 instance codex_pool (disabled)"], bin: "codex" }, + { harness: "codex", dir: "/opt/codex-env", from: ["T3 instance codex_env (disabled)"], bin: "codex" }, + { harness: "claude", dir: "/srv/claude", from: ["CLAUDE_CONFIG_DIR"], bin: "claude" }, + { harness: "codex", dir: "/data/codex", from: ["--codex-home"], bin: "codex" }, + ], + settings: { path: join(t3, "userdata/settings.json"), instances: 6, error: undefined }, + }); + }); + + test("expands a leading ~ in an instance's home variable and binary as T3 does", () => { + const t3 = tmp(); + writeJson(join(t3, "userdata/settings.json"), { + providerInstances: { + claude_env: { driver: "claudeAgent", environment: [{ name: "CLAUDE_CONFIG_DIR", value: "~/.claude-env" }], config: { binaryPath: "~/bin/claude" } }, + codex_env: { driver: "codex", environment: [{ name: "CODEX_HOME", value: "~\\.codex-env" }] }, + }, + }); + const { homes } = discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" }); + expect(homes.slice(2)).toEqual([ + { harness: "claude", dir: "/home/u/.claude-env", from: ["T3 instance claude_env"], bin: "/home/u/bin/claude" }, + { harness: "codex", dir: "/home/u/.codex-env", from: ["T3 instance codex_env"], bin: "codex" }, + ]); + }); + + test("reports T3 settings that do not parse without quoting them", () => { + const t3 = tmp(); + mkdirSync(join(t3, "userdata"), { recursive: true }); + writeFileSync(join(t3, "userdata/settings.json"), '{"providerInstances": {"codex": {"environment": [{"name": "OPENAI_API_KEY", "value": "sk-live-123'); + expect(discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" }).settings).toEqual({ + path: join(t3, "userdata/settings.json"), + instances: 0, + error: "invalid JSON", + }); + }); + + test("falls back to the defaults when T3's settings are missing", () => { + const t3 = tmp(); + expect(discoverHomes({ env: { T3CODE_HOME: t3 }, home: "/home/u" })).toEqual({ + homes: [ + { harness: "claude", dir: "/home/u/.claude", from: ["default"], bin: "claude" }, + { harness: "codex", dir: "/home/u/.codex", from: ["default"], bin: "codex" }, + ], + settings: { path: join(t3, "userdata/settings.json"), instances: 0, error: "not found" }, + }); + }); +}); + +// The record a second config home keeps re-acquiring for the home folder. +function claudeHome({ autoUpdate } = {}) { + const dir = tmp(); + const user = { + scope: "user", + installPath: `${dir}/plugins/cache/t3-pstack/t3-pstack/2.0.0-rc.3`, + version: "2.0.0-rc.3", + installedAt: "2026-10-06T08:38:28.344Z", + lastUpdated: "2026-10-14T11:20:44.228Z", + gitCommitSha: "1111111111111111111111111111111111111111", + }; + const project = { + scope: "project", + projectPath: "/home/u", + installPath: `${dir}/plugins/cache/t3-pstack/t3-pstack/1.0.11`, + version: "1.0.11", + installedAt: "2026-10-13T04:43:26.759Z", + lastUpdated: "2026-10-13T04:43:26.759Z", + gitCommitSha: "0000000000000000000000000000000000000000", + }; + const other = { scope: "project", projectPath: "/home/u", installPath: "/x", version: "0.1.0" }; + writeJson(join(user.installPath, ".claude-plugin/plugin.json"), { name: "t3-pstack", version: user.version }); + for (const [name, text] of skills) { + mkdirSync(join(user.installPath, "skills", name), { recursive: true }); + writeFileSync(join(user.installPath, "skills", name, "SKILL.md"), text); + } + writeJson(join(dir, "plugins/installed_plugins.json"), { + version: 2, + plugins: { "t3-pstack@t3-pstack": [user, project], "other@market": [other] }, + }); + writeJson(join(dir, "settings.json"), { + enabledPlugins: { "t3-pstack@t3-pstack": true }, + extraKnownMarketplaces: { + "t3-pstack": { source: { source: "github", repo: "chhoumann/t3-pstack" }, ...(autoUpdate && { autoUpdate }) }, + }, + }); + return { dir, user, project, other }; +} + +describe("a stale other-scope Claude Code record", () => { + test("moves to the user-scope install, after a backup, and only once", () => { + const { dir, user, project, other } = claudeHome(); + const path = join(dir, "plugins/installed_plugins.json"); + const original = readFileSync(path, "utf8"); + + const steps = syncScopedRecords(dir); + const backups = readdirSync(join(dir, "plugins")).filter((name) => name.endsWith(".bak")); + expect(backups).toHaveLength(1); + expect(steps).toEqual([ + "moved the project-scope record for /home/u from 1.0.11 to 2.0.0-rc.3", + `backed up plugins/installed_plugins.json to ${join(dir, "plugins", backups[0])}`, + ]); + expect(readFileSync(join(dir, "plugins", backups[0]), "utf8")).toBe(original); + expect(JSON.parse(readFileSync(path, "utf8"))).toEqual({ + version: 2, + plugins: { + "t3-pstack@t3-pstack": [ + user, + { ...project, installPath: user.installPath, version: user.version, gitCommitSha: user.gitCommitSha, lastUpdated: user.lastUpdated }, + ], + "other@market": [other], + }, + }); + + const fixed = readFileSync(path, "utf8"); + expect(syncScopedRecords(dir)).toEqual([]); + expect(readFileSync(path, "utf8")).toBe(fixed); + expect(readdirSync(join(dir, "plugins")).filter((name) => name.endsWith(".bak"))).toHaveLength(1); + }); + + test("keeps the records file's mode, and its backup's", () => { + const { dir } = claudeHome(); + const path = join(dir, "plugins/installed_plugins.json"); + chmodSync(path, 0o600); + syncScopedRecords(dir); + const backup = readdirSync(join(dir, "plugins")).find((name) => name.endsWith(".bak")); + expect([mode(path), mode(join(dir, "plugins", backup))]).toEqual([0o600, 0o600]); + }); + + test("doctor names it, the missing auto-update, and plain skill copies, and passes once they are fixed", () => { + const { dir } = claudeHome(); + mkdirSync(join(dir, "skills/how"), { recursive: true }); + writeFileSync(join(dir, "skills/how/SKILL.md"), SKILL("how")); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(doctorClaude(home, context)).toEqual([ + { name: "marketplace", ok: true, detail: "chhoumann/t3-pstack" }, + { name: "installed", ok: true, detail: "2.0.0-rc.3" }, + { name: "enabled", ok: true, detail: "yes" }, + { name: "marketplace auto-update", ok: false, detail: "false", fix: FIX }, + { name: "other-scope records", ok: false, detail: "project /home/u at 1.0.11", fix: FIX }, + { + name: "no plain copies of plugin skills", + ok: false, + detail: join(dir, "skills/how"), + fix: "delete these folders; the plugin ships the same skills", + }, + ]); + expect(doctorClaude(home, { ...context, version: "2.0.0-rc.4" })[1]).toEqual({ + name: "installed", + ok: false, + detail: "2.0.0-rc.3, expected 2.0.0-rc.4", + fix: FIX, + }); + + syncScopedRecords(dir); + rmSync(join(dir, "skills"), { recursive: true }); + const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")); + settings.extraKnownMarketplaces["t3-pstack"].autoUpdate = true; + writeJson(join(dir, "settings.json"), settings); + expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([]); + }); + + test("doctor leaves a user's own skill that shares a plugin skill's name", () => { + const { dir } = claudeHome({ autoUpdate: true }); + syncScopedRecords(dir); + mkdirSync(join(dir, "skills/how"), { recursive: true }); + writeFileSync(join(dir, "skills/how/SKILL.md"), "---\nname: how\ndescription: My own notes on how things work.\n---\n"); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([]); + }); + + test("doctor fails an install whose files are gone, and install reinstalls it", () => { + const { dir, user } = claudeHome({ autoUpdate: true }); + syncScopedRecords(dir); + rmSync(user.installPath, { recursive: true }); + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(doctorClaude(home, context).filter((c) => !c.ok)).toEqual([ + { name: "installed", ok: false, detail: `2.0.0-rc.3, but its files at ${user.installPath} are missing`, fix: FIX }, + ]); + expect(installClaude(home, context)).toEqual(["reinstalled 2.0.0-rc.3, whose files were missing"]); + expect(claude.calls()).toEqual(["plugin install t3-pstack@t3-pstack --scope user"]); + }); + + test("install says so when the marketplace has no newer version to update to", () => { + const { dir } = claudeHome({ autoUpdate: true }); + syncScopedRecords(dir); + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(installClaude(home, { ...context, version: "2.0.0-rc.4" })).toEqual(["the marketplace has no 2.0.0-rc.4, so it stays at 2.0.0-rc.3"]); + expect(claude.calls()).toEqual(["plugin marketplace update t3-pstack", "plugin update t3-pstack@t3-pstack --scope user"]); + }); + + test("doctor reports settings.json that does not parse by path alone", () => { + const { dir } = claudeHome(); + writeFileSync(join(dir, "settings.json"), '{"env": {"ANTHROPIC_API_KEY": "sk-ant-live-123"'); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(() => doctorClaude(home, context)).toThrow(new Error(`${join(dir, "settings.json")}: invalid JSON`)); + }); + + test("turning on auto-update keeps settings.json's mode", () => { + const { dir } = claudeHome(); + chmodSync(join(dir, "settings.json"), 0o600); + const claude = fakeClaude(); + const steps = installClaude({ harness: "claude", dir, from: ["default"], bin: claude.bin }, context); + expect(steps[0]).toBe("turned on marketplace auto-update"); + expect(mode(join(dir, "settings.json"))).toBe(0o600); + }); +}); + +describe("a t3-pstack marketplace from another source", () => { + function counterfeit() { + const { dir } = claudeHome({ autoUpdate: true }); + const elsewhere = tmp(); + const settings = JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")); + settings.extraKnownMarketplaces["t3-pstack"].source = { source: "directory", path: elsewhere }; + writeJson(join(dir, "settings.json"), settings); + rmSync(join(dir, "plugins/installed_plugins.json")); + return { dir, elsewhere }; + } + + test("installs nothing, and doctor fails it with the source", () => { + const { dir, elsewhere } = counterfeit(); + const claude = fakeClaude(); + const home = { harness: "claude", dir, from: ["default"], bin: claude.bin }; + expect(installClaude(home, context)).toEqual([]); + expect(claude.calls()).toEqual([]); + expect(doctorClaude(home, context)[0]).toEqual({ + name: "marketplace", + ok: false, + detail: `${elsewhere}, which is neither chhoumann/t3-pstack nor --source`, + fix: `if you trust it, rerun with --source ${elsewhere}; otherwise remove it with \`CLAUDE_CONFIG_DIR=${dir} claude plugin marketplace remove t3-pstack\`, then ${FIX}`, + }); + }); + + test("is trusted when it is this run's --source", () => { + const { dir, elsewhere } = counterfeit(); + const local = parseSource(elsewhere, "/home/u"); + const home = { harness: "claude", dir, from: ["default"], bin: "claude" }; + expect(doctorClaude(home, { ...context, source: local, trusted: new Set([github.key, local.key]) })[0]).toEqual({ + name: "marketplace", + ok: true, + detail: elsewhere, + }); + }); +}); + +describe("--source", () => { + test("reads owner/repo as GitHub even where that relative directory exists, and a URL to the same repository as the same source", () => { + const cwd = tmp(); + mkdirSync(join(cwd, "chhoumann/t3-pstack"), { recursive: true }); + const r = spawnSync(process.execPath, [join(repoRoot, "tools/install.mjs"), "doctor", "--source", "chhoumann/t3-pstack", "--json"], { + cwd, + env: { HOME: tmp(), PATH: tmp() }, + encoding: "utf8", + }); + expect({ status: r.status, stderr: r.stderr, source: r.status === 0 && JSON.parse(r.stdout).source }).toEqual({ + status: 0, + stderr: "", + source: "chhoumann/t3-pstack", + }); + expect(parseSource("git@github.com:ChHoumann/t3-pstack.git", "/home/u").key).toBe(github.key); + 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"); + }); +}); + +describe("files the installer replaces", () => { + test("keep their mode", () => { + const path = join(tmp(), "settings.json"); + writeFileSync(path, "{}\n", { mode: 0o600 }); + writeAtomic(path, '{"a": 1}\n'); + expect([readFileSync(path, "utf8"), mode(path)]).toEqual(['{"a": 1}\n', 0o600]); + }); +}); + +describe("codex app-server output that is not JSON-RPC", () => { + test("fails the request instead of crashing the run", async () => { + const dir = tmp(); + const bin = join(dir, "codex"); + writeFileSync(bin, "#!/bin/sh\necho 'token sk-live-123 expired'\nexec cat >/dev/null\n"); + chmodSync(bin, 0o755); + await expect(AppServer.start({ dir, bin })).rejects.toThrow(new Error("codex app-server printed a line that is not JSON-RPC")); + }); +}); + +describe("the model sheet", () => { + const SHEET = "# budget: large (xhigh)\nbug-fix: inherit-parent\nsession hook: on\n"; + + function machine() { + const root = tmp(); + const homes = [".claude", ".codex", ".codex-pool", ".claude-never-run"].map((name) => ({ dir: join(root, name) })); + for (const { dir } of homes.slice(0, 3)) mkdirSync(dir); + return { root, homes, sheet: join(root, ".config/t3-pstack/models.md") }; + } + + test("moves a linked home sheet to the machine path once and links every existing home to it", () => { + const { root, homes, sheet } = machine(); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); + symlinkSync(join(root, ".claude/t3-pstack-models.md"), join(root, ".codex/t3-pstack-models.md")); + + expect(moveSheet(sheet, homes)).toEqual([ + `moved the sheet from ${join(root, ".claude/t3-pstack-models.md")}`, + `linked ${join(root, ".claude/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(readFileSync(sheet, "utf8")).toBe(SHEET); + expect(readlinkSync(join(root, ".claude/t3-pstack-models.md"))).toBe(sheet); + expect(readlinkSync(join(root, ".codex/t3-pstack-models.md"))).toBe(join(root, ".claude/t3-pstack-models.md")); + expect(readlinkSync(join(root, ".codex-pool/t3-pstack-models.md"))).toBe(sheet); + expect(existsSync(join(root, ".claude-never-run"))).toBe(false); + + expect(moveSheet(sheet, homes)).toEqual([]); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).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" }, + ]); + }); + + test("links the machine path to a home sheet's file outside every home instead of copying it", () => { + const { root, homes, sheet } = machine(); + const dotfiles = join(root, "dotfiles/t3-pstack-models.md"); + mkdirSync(join(root, "dotfiles")); + writeFileSync(dotfiles, SHEET); + symlinkSync(dotfiles, join(root, ".claude/t3-pstack-models.md")); + writeFileSync(join(root, ".codex/t3-pstack-models.md"), SHEET); + + expect(moveSheet(sheet, homes)).toEqual([ + `linked the sheet to ${dotfiles}`, + `linked ${join(root, ".codex/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(readlinkSync(sheet)).toBe(dotfiles); + expect(readlinkSync(join(root, ".claude/t3-pstack-models.md"))).toBe(dotfiles); + expect(readFileSync(dotfiles, "utf8")).toBe(SHEET); + expect(moveSheet(sheet, homes)).toEqual([]); + }); + + test("keeps a home sheet that links to another file, even one that matches the machine sheet", () => { + const { root, homes, sheet } = machine(); + mkdirSync(join(root, ".config/t3-pstack"), { recursive: true }); + writeFileSync(sheet, SHEET); + mkdirSync(join(root, "dotfiles")); + writeFileSync(join(root, "dotfiles/models.md"), SHEET); + symlinkSync(join(root, "dotfiles/models.md"), join(root, ".claude/t3-pstack-models.md")); + + expect(moveSheet(sheet, homes)).toEqual([ + `linked ${join(root, ".codex/t3-pstack-models.md")} to it`, + `linked ${join(root, ".codex-pool/t3-pstack-models.md")} to it`, + ]); + expect(readlinkSync(join(root, ".claude/t3-pstack-models.md"))).toBe(join(root, "dotfiles/models.md")); + }); + + test("moves a sheet with its mode, and leaves a file beside it that only shares the old temp name", () => { + const { root, homes, sheet } = machine(); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET, { mode: 0o600 }); + writeFileSync(join(root, ".claude/t3-pstack-models.md.t3-pstack-link"), "mine\n"); + + moveSheet(sheet, homes); + expect(mode(sheet)).toBe(0o600); + expect(readFileSync(join(root, ".claude/t3-pstack-models.md.t3-pstack-link"), "utf8")).toBe("mine\n"); + expect(readdirSync(join(root, ".claude")).sort()).toEqual(["t3-pstack-models.md", "t3-pstack-models.md.t3-pstack-link"]); + }); + + test("leaves home sheets that disagree, and doctor says so", () => { + const { root, homes, sheet } = machine(); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); + writeFileSync(join(root, ".codex/t3-pstack-models.md"), "session hook: off\n"); + + expect(moveSheet(sheet, homes)).toEqual([]); + expect(existsSync(sheet)).toBe(false); + expect(doctorSheet(sheet, homes, { catalog: null, roles, tiers })).toEqual([ + { + name: "machine sheet", + ok: false, + detail: `missing; ${join(root, ".claude/t3-pstack-models.md")}, ${join(root, ".codex/t3-pstack-models.md")} differ, so the installer leaves them`, + fix: `keep one, then ${FIX}`, + }, + ]); + }); + + test("keeps a home sheet that differs from an existing machine sheet, and links one that matches", () => { + const { root, homes, sheet } = machine(); + mkdirSync(join(root, ".config/t3-pstack"), { recursive: true }); + writeFileSync(sheet, SHEET); + writeFileSync(join(root, ".claude/t3-pstack-models.md"), SHEET); + writeFileSync(join(root, ".codex/t3-pstack-models.md"), "session hook: off\n"); + + expect(moveSheet(sheet, homes)).toEqual([ + `linked ${join(root, ".claude/t3-pstack-models.md")} to it`, + `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({ + name: "home sheets", + ok: false, + detail: `${join(root, ".codex/t3-pstack-models.md")} differ from the machine sheet`, + fix: `move each aside, then ${FIX} to link it`, + }); + }); + + 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([ + { + name: "machine sheet", + ok: false, + detail: "none, so every role runs on its skill's defaults", + fix: "run /t3-pstack:setup-pstack in a T3 thread", + warn: true, + }, + ]); + 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({ + name: "machine sheet valid", + ok: false, + detail: 'bug-fix: "claude-opus" does not start with <providerInstanceId>/<modelId>; "how critics" is not a pstack role', + fix: "run /t3-pstack:setup-pstack in a T3 thread", + }); + }); +}); diff --git a/tests/readme-facts.test.mjs b/tests/readme-facts.test.mjs index f55bfdc1..591b43d5 100644 --- a/tests/readme-facts.test.mjs +++ b/tests/readme-facts.test.mjs @@ -24,6 +24,11 @@ describe("README facts match the tree", () => { expect(numbersBefore("agents")).toEqual([agents]); }); + test("the pinned installer command names this release's tag", () => { + const version = readFileSync(join(repoRoot, "VERSION"), "utf8").trim(); + expect([...readme.matchAll(/npx -y github:chhoumann\/t3-pstack#v([^\s`]+)/g)].map((m) => m[1])).toEqual([version]); + }); + test("the documented upstream pin matches every component's pin", () => { const named = [...readme.matchAll(/synced against upstream `([0-9a-f]+)`/g)].map((m) => m[1]); expect(named).toHaveLength(1); diff --git a/tests/session-hook-windows.test.mjs b/tests/session-hook-windows.test.mjs index ec3ff828..3a65c6ac 100644 --- a/tests/session-hook-windows.test.mjs +++ b/tests/session-hook-windows.test.mjs @@ -12,7 +12,7 @@ const manifest = JSON.parse(readFileSync(join(pluginRoot, ".codex-plugin/plugin. const handler = JSON.parse(readFileSync(join(pluginRoot, manifest.hooks), "utf8")).hooks.SessionStart[0].hooks[0]; const mandate = readFileSync(join(pluginRoot, "hooks/session-start-context.md"), "utf8"); -function runHook({ sheet, codexHome, homeSheet, context = mandate, host = "cmd" } = {}) { +function runHook({ sheet, codexHome, homeSheet, machineSheet, context = mandate, host = "cmd" } = {}) { const fixture = mkdtempSync(join(tmpdir(), "pstack windows hook ")); try { const plugin = join(fixture, "plugin with spaces"); @@ -25,6 +25,10 @@ function runHook({ sheet, codexHome, homeSheet, context = mandate, host = "cmd" writeSheet(join(dir, "t3-pstack-models.md"), content); } } + if (machineSheet !== undefined) { + mkdirSync(join(profile, ".config/t3-pstack"), { recursive: true }); + writeSheet(join(profile, ".config/t3-pstack/models.md"), machineSheet); + } const contextPath = join(plugin, "hooks/session-start-context.md"); if (context === null) rmSync(contextPath); else writeFileSync(contextPath, context); @@ -82,6 +86,13 @@ describe.skipIf(process.platform !== "win32" && !required)("Windows Codex Sessio }); }); + test("the machine sheet decides over the Codex home sheet", () => { + expect(runHook({ machineSheet: "session hook: off\n", sheet: "session hook: on\n" })).toEqual({ status: 0, out: "", err: "" }); + expect(runHook({ machineSheet: "session hook: on\n", sheet: "session hook: off\n" })).toEqual({ + status: 0, out: mandate, err: "", + }); + }); + for (const { name, sheet, off } of sheetCases) { test(`${off ? "injects nothing" : "injects the context"} when the sheet has ${name}`, () => { expect(runHook({ sheet })).toEqual({ status: 0, out: off ? "" : mandate, err: "" }); diff --git a/tests/session-hook.test.mjs b/tests/session-hook.test.mjs index 9e97f5c7..a907b4b0 100644 --- a/tests/session-hook.test.mjs +++ b/tests/session-hook.test.mjs @@ -1,5 +1,6 @@ // Each runtime's shipped SessionStart command, run for real: the mandate is -// injected unless that runtime's model sheet turns it off. +// injected unless the model sheet turns it off. The sheet is the machine's, +// or, until the installer moves it there, that runtime's home sheet. import { describe, expect, test } from "bun:test"; import { spawnSync } from "node:child_process"; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -70,26 +71,6 @@ describe("SessionStart hook", () => { expect(sessionStart.codex.matcher).toBe("startup|resume|clear|compact"); }); - // Codex trusts a plugin hook by a hash of its entry, so a changed command, - // matcher, or timeout silently stops routing on Codex until the user trusts - // it again. The script and the routing text can change freely. - test("keeps the hook entries released in 1.0.14", () => { - expect(sessionStart.claude).toEqual({ - matcher: "startup|resume|clear|compact", - hooks: [{ type: "command", command: '"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" claude' }], - }); - expect(sessionStart.codex).toEqual({ - matcher: "startup|resume|clear|compact", - hooks: [ - { - type: "command", - command: '"${CLAUDE_PLUGIN_ROOT}/hooks/session-start.sh" codex', - commandWindows: 'powershell.exe -NoProfile -ExecutionPolicy Bypass -File "${CLAUDE_PLUGIN_ROOT}/hooks/session-start.ps1"', - }, - ], - }); - }); - test("names only skills that exist", () => { const named = [...mandate.matchAll(/(?:pstack:|`\/)([a-z0-9-]+)/g)].map((m) => m[1]); const skills = new Set(agentSkills(join(pluginRoot, "skills")).map(({ name }) => name)); @@ -132,7 +113,44 @@ describe("SessionStart hook", () => { } }); -describe("each host reads only its own sheet", () => { +describe("the machine sheet", () => { + const run = (runtime, home, env = {}) => + spawnSync("sh", ["-c", sessionStart[runtime].hooks[0].command], { + env: { PATH: process.env.PATH, HOME: home, CLAUDE_PLUGIN_ROOT: pluginRoot, ...env }, + encoding: "utf8", + }).stdout; + + test.each(["claude", "codex"])("decides over the %s home sheet", (runtime) => { + const home = mkdtempSync(join(tmpdir(), "pstack-hook-")); + try { + const machineSheet = join(home, ".config/t3-pstack/models.md"); + mkdirSync(join(home, ".config/t3-pstack"), { recursive: true }); + mkdirSync(join(home, `.${runtime}`)); + writeFileSync(machineSheet, "session hook: off\n"); + writeFileSync(join(home, `.${runtime}`, "t3-pstack-models.md"), "session hook: on\n"); + expect(run(runtime, home)).toBe(""); + writeFileSync(machineSheet, "session hook: on\n"); + writeFileSync(join(home, `.${runtime}`, "t3-pstack-models.md"), "session hook: off\n"); + expect(run(runtime, home)).toBe(mandate); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + test.each(["claude", "codex"])("moves with XDG_CONFIG_HOME on %s", (runtime) => { + const home = mkdtempSync(join(tmpdir(), "pstack-hook-")); + try { + mkdirSync(join(home, "xdg/t3-pstack"), { recursive: true }); + writeFileSync(join(home, "xdg/t3-pstack/models.md"), "session hook: off\n"); + expect(run(runtime, home)).toBe(mandate); + expect(run(runtime, home, { XDG_CONFIG_HOME: join(home, "xdg") })).toBe(""); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("without a machine sheet, each host reads only its own home sheet", () => { test("an off sheet in the other host's config directory leaves injection on", () => { const home = mkdtempSync(join(tmpdir(), "pstack-hook-")); try { diff --git a/tests/t3-sheet.test.mjs b/tests/t3-sheet.test.mjs index 81d6d867..cc9d58aa 100644 --- a/tests/t3-sheet.test.mjs +++ b/tests/t3-sheet.test.mjs @@ -66,6 +66,16 @@ describe("parseEntry", () => { }); }); +describe("checkSheet without a catalog", () => { + test("checks the grammar and the panel rule, not whether a target dispatches here", () => { + expect(check("bug-fix: someprovider/unlisted-model effort=max\nsession hook: on\n", null)).toEqual([]); + expect(check(`bug-fix: claude-opus\n${PANEL}: someprovider/claude-a, otherprovider/claude-b\n`, null)).toEqual([ + 'bug-fix: "claude-opus" does not start with <providerInstanceId>/<modelId>', + `${PANEL}: a panel needs at least two model families among its targets (inherit-parent does not count), got claude`, + ]); + }); +}); + describe("checkSheet against a catalog", () => { test("the default sheet setup-pstack writes dispatches", () => { const setup = readFileSync(join(root, "plugins/pstack/skills/setup-pstack/SKILL.md"), "utf8"); diff --git a/tools/derive-t3.mjs b/tools/derive-t3.mjs index c04aa821..e76cca94 100644 --- a/tools/derive-t3.mjs +++ b/tools/derive-t3.mjs @@ -77,9 +77,10 @@ export const fenceUnder = (title, lang) => (lines) => { }; const SHEET = - "A role line in the model sheet overrides each one. The sheet is `t3-pstack-models.md` in the host's config " + - "directory (`$CLAUDE_CONFIG_DIR` or `~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex), and " + - "`/setup-pstack` writes it. An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it " + + "A role line in the model sheet overrides each one. The sheet is `~/.config/t3-pstack/models.md` " + + "(`$XDG_CONFIG_HOME/t3-pstack/models.md` when that is set), one per machine, and `/setup-pstack` writes it. " + + "When it is missing, read `t3-pstack-models.md` in the host's config directory (`$CLAUDE_CONFIG_DIR` or " + + "`~/.claude` on Claude Code, `$CODEX_HOME` or `~/.codex` on Codex). An entry is `<providerInstanceId>/<modelId>` plus `<optionId>=<value>` pairs, and it " + "maps onto `delegate_task`'s `target`, with the provider instance as `providerInstanceId`, the model as `model`, " + "and the pairs as `options`. It splits at its first `/`, so a model ID with its own `/`, such as OpenCode's " + "`<provider>/<model>`, is written whole after it. Option IDs differ per provider, so take them from " + diff --git a/tools/frozen-hooks.json b/tools/frozen-hooks.json new file mode 100644 index 00000000..3cc4b014 --- /dev/null +++ b/tools/frozen-hooks.json @@ -0,0 +1,10 @@ +{ + "plugins/pstack/hooks/hooks.json": { + "sha256": "95229a0e55867feef1b7e85e21738cf3df63ba66e79d3a2a805c73d683509c3b", + "changedIn": "0.9.47" + }, + "plugins/pstack/hooks/codex-hooks.json": { + "sha256": "40d3c6f0c4b4038d900bb0e84eda481dd1f42e9069506431abe6adfdefba5123", + "changedIn": "0.9.66" + } +} diff --git a/tools/generate.mjs b/tools/generate.mjs index 6a3f1178..92667033 100644 --- a/tools/generate.mjs +++ b/tools/generate.mjs @@ -20,8 +20,9 @@ // // Also validated: skill frontmatter, the skills tree's links, // the plugin layout, agent frontmatter, the Codex marketplace, and both hooks -// files. +// files against their pins in tools/frozen-hooks.json. +import { execFileSync } from "node:child_process"; import { existsSync, lstatSync, @@ -41,7 +42,7 @@ import { Value } from "typebox/value"; import { panelFamilies, parseEntry } from "../plugins/pstack/skills/setup-pstack/scripts/t3-sheet.mjs"; import { applyRegions, parseFrontmatter, ROUTED_SKILL, t3Regions } from "./derive-t3.mjs"; -import { PLUGIN, SKILLS } from "./plugin.mjs"; +import { FROZEN_HOOKS, hookDigest, PLUGIN, SKILLS } from "./plugin.mjs"; import { pathIsInside, validateSkillsTree, walk } from "./validate-skills.mjs"; export { parseFrontmatter }; @@ -378,6 +379,60 @@ export function validateHooks(hooksJson, { statOf, file = "hooks/hooks.json", ro if (faults.length) throw new Error(`${file}:\n ${faults.join("\n ")}`); } +// Codex trusts a plugin hook by a hash of its entry, and a Git marketplace +// delivers a new entry at the next app-server start, so a changed hooks file +// silently stops the hook in every home until the installer trusts it again. +// Each hooks file is pinned with the release that last changed it, and that +// release's CHANGES entry must tell users to rerun the installer. `shipped` +// holds VERSION and the hooks files as origin/main has them, since main is +// what the marketplaces deliver: a file that differs from main's changes in +// this release, so its pin must name this release, and VERSION must be newer +// than main's. +export function frozenHookFaults({ pins, files, version, changelog, shipped }) { + const faults = Object.keys(files) + .filter((path) => !Object.hasOwn(pins, path)) + .map((path) => `${path} has no pin in ${FROZEN_HOOKS}`); + const entry = changelog.split(/^## /m).find((section) => section.startsWith(`${version} `)) ?? ""; + for (const [path, pin] of Object.entries(pins)) { + if (!Object.hasOwn(files, path)) { + faults.push(`${FROZEN_HOOKS} pins ${path}, which no manifest names`); + continue; + } + const digest = hookDigest(files[path]); + const changed = shipped.files[path] === undefined || hookDigest(shipped.files[path]) !== digest; + if (digest !== pin.sha256) { + faults.push( + `${path} changed, which stops its hook in every Codex home until the installer reruns. ` + + `Pin "sha256": "${digest}" and "changedIn": "${version}" in ${FROZEN_HOOKS}, ` + + `and tell users to rerun the installer in CHANGES.md's ${version} entry.`, + ); + } else if (changed && Bun.semver.order(version, shipped.version) <= 0) { + faults.push(`${path} differs from origin/main, which ships ${shipped.version}, so this change needs a VERSION newer than that, not ${version}`); + } else if (changed && pin.changedIn !== version) { + faults.push(`${path} differs from origin/main, so its pin's "changedIn" must be ${version}, not ${pin.changedIn}`); + } else if (pin.changedIn === version && !/rerun the installer/i.test(entry)) { + faults.push(`${path} changes in ${version}, but CHANGES.md's ${version} entry does not tell users to rerun the installer`); + } + } + return faults; +} + +// VERSION and `paths` as origin/main has them now, with undefined for a path +// main lacks. It fetches main first, so a branch cut before main's latest +// release is compared with that release and not with the merge-base. +function shippedOnMain(root, paths) { + const git = (...args) => execFileSync("git", ["-C", root, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }); + const main = "refs/remotes/origin/main"; + try { + git("fetch", "--quiet", "--no-tags", "origin", `+refs/heads/main:${main}`); + } catch (err) { + throw new Error(`${FROZEN_HOOKS}: cannot fetch origin/main to compare the hooks files with: ${(err.stderr || err.message).trim()}`); + } + const listed = new Set(git("ls-tree", "-r", "--name-only", main, "--", "VERSION", ...paths).split("\n")); + const show = (path) => (listed.has(path) ? git("show", `${main}:${path}`) : undefined); + return { version: show("VERSION")?.trim(), files: Object.fromEntries(paths.map((path) => [path, show(path)])) }; +} + // Every file the generator writes, as exact text by repo-relative path, // computed from the sources under `root` without writing. Any other entry in // an owned directory is an orphan. Throws when a source cannot be planned. @@ -489,9 +544,22 @@ export function problems(root, models) { if (!existsSync(full) || !pathIsInside(realpathSync(pluginRoot), realpathSync(full))) return null; return statSync(full); }; - for (const file of ["hooks/hooks.json", ...(codex?.hooks ? [codex.hooks.replace(/^\.\//, "")] : [])]) { + const hookFiles = ["hooks/hooks.json", ...(codex?.hooks ? [codex.hooks.replace(/^\.\//, "")] : [])]; + for (const file of hookFiles) { attempt(() => validateHooks(readFileSync(join(pluginRoot, file), "utf8"), { statOf, file })); } + // A Codex manifest that does not parse names no hooks file to compare. + if (codex) attempt(() => { + const paths = hookFiles.map((file) => `${PLUGIN}/${file}`); + const faults = frozenHookFaults({ + pins: JSON.parse(read(FROZEN_HOOKS)), + files: Object.fromEntries(paths.map((path) => [path, read(path)])), + version: read("VERSION").trim(), + changelog: read("CHANGES.md"), + shipped: shippedOnMain(root, paths), + }); + if (faults.length) throw new Error(faults.join("\n")); + }); return failures; } diff --git a/tools/install.mjs b/tools/install.mjs new file mode 100755 index 00000000..2da40c19 --- /dev/null +++ b/tools/install.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +// t3-pstack's installer. `install`, or `update`, the same command, brings +// every Claude Code and Codex home T3 can run to this package's version of +// t3-pstack, trusts the Codex session-start hook, and moves the model sheet to +// its one path per machine. Each step converges, so a second run changes +// nothing. `doctor` checks the same homes and writes nothing itself, though +// the Codex app-server it starts refreshes a Git marketplace, as any T3 Codex +// thread does. Both exit 1 when a check fails. +// +// 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. +// +// npx -y github:chhoumann/t3-pstack <install|update|doctor> +// --source <owner/repo|git URL|dir> marketplace source, default chhoumann/t3-pstack +// --claude-home <dir>, --codex-home <dir> more homes, repeatable +// --catalog <file> 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 { homedir } from "node:os"; +import { join } 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 { discoverHomes } from "./install/homes.mjs"; +import { doctorSheet, moveSheet, sheetPath } from "./install/sheet.mjs"; +import { FROZEN_HOOKS } from "./plugin.mjs"; + +const HARNESSES = { + claude: { name: "Claude Code", install: installClaude, doctor: doctorClaude }, + codex: { name: "Codex", install: installCodex, doctor: doctorCodex }, +}; + +const USAGE = + "usage: t3-pstack <install|update|doctor> [--source <owner/repo|git URL|dir>] " + + "[--claude-home <dir>]... [--codex-home <dir>]... [--catalog <file>] [--json]"; + +// 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` +// maps each skill to its SKILL.md, and `codexHook` is the hooks file the +// Codex manifest names, with its pinned digest. +function pluginFacts(root) { + const plugin = join(root, "plugins/pstack"); + const { roles, tiers } = readJson(join(plugin, "models.json")); + const hooks = readJson(join(plugin, ".codex-plugin/plugin.json")).hooks.replace(/^\.\//, ""); + const skills = readdirSync(join(plugin, "skills"), { withFileTypes: true }).filter((entry) => entry.isDirectory()); + return { + version: readJson(join(plugin, ".claude-plugin/plugin.json")).version, + skills: new Map(skills.map(({ name }) => [name, readFileSync(join(plugin, "skills", name, "SKILL.md"), "utf8")])), + codexHook: { path: hooks, sha256: readJson(join(root, FROZEN_HOOKS))[`plugins/pstack/${hooks}`].sha256 }, + roles, + tiers, + }; +} + +async function run(argv, env, home) { + const { values, positionals } = parseArgs({ + args: argv, + allowPositionals: true, + options: { + source: { type: "string" }, + "claude-home": { type: "string", multiple: true, default: [] }, + "codex-home": { type: "string", multiple: true, default: [] }, + catalog: { type: "string" }, + json: { type: "boolean", default: false }, + }, + }); + const [command] = positionals; + if (positionals.length !== 1 || !["install", "update", "doctor"].includes(command)) return { usage: true }; + + const source = parseSource(values.source ?? HOME_REPO, 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 { homes, settings } = discoverHomes({ + env, + home, + claudeHomes: values["claude-home"], + codexHomes: values["codex-home"], + }); + const catalog = values.catalog ? readJson(values.catalog) : null; + + const report = { command, version: facts.version, source: source.arg, 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: [] }; + report.homes.push(row); + if (!hasCli(target.bin)) { + row.checks.push(check("CLI", false, `${target.bin} not found, so this home was skipped`, `install ${harness.name} if T3 runs it`, true)); + continue; + } + if (command === "doctor" && !existsSync(target.dir)) { + row.checks.push(check("installed", false, "the home does not exist", INSTALL_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)); + } + } + 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(...doctorSheet(sheet, homes, { catalog, roles: facts.roles, tiers: facts.tiers })); + 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; + return { report, json: values.json }; +} + +function render({ command, version, source, settings, homes, sheet, failed, warnings }, home) { + const lines = [`t3-pstack ${version} from ${source}`]; + 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); + for (const step of steps) lines.push(` - ${step}`); + for (const c of checks) { + lines.push(` ${c.ok ? "ok " : c.warn ? "warn" : "FAIL"} ${c.name}: ${c.detail}`); + if (!c.ok) lines.push(` fix: ${c.fix}`); + } + }; + for (const row of homes) section(`${HARNESSES[row.harness].name} ${row.dir} (${row.from.join(", ")})`, row.steps, row.checks); + section(`Model sheet ${sheet.path}`, sheet.steps, sheet.checks); + const changed = [...homes.flatMap((row) => row.steps), ...sheet.steps].length; + const count = (n, noun) => `${n} ${noun}${n === 1 ? "" : "s"}`; + lines.push( + "", + `${command === "doctor" ? "" : changed ? `${count(changed, "change")}. ` : "Nothing to change. "}` + + (failed ? `${count(failed, "check")} failed` : "Every check passes") + + (warnings ? `, ${count(warnings, "warning")}.` : "."), + ); + return lines.join("\n").replaceAll(home, "~"); +} + +const home = homedir(); +try { + const { usage, report, json } = await run(process.argv.slice(2), process.env, home); + if (usage) { + console.error(USAGE); + process.exitCode = 2; + } else { + console.log(json ? JSON.stringify(report, null, 2) : render(report, home)); + process.exitCode = report.failed ? 1 : 0; + } +} catch (err) { + console.error(`t3-pstack: ${err.message}`); + process.exitCode = 2; +} diff --git a/tools/install/claude.mjs b/tools/install/claude.mjs new file mode 100644 index 00000000..52956f96 --- /dev/null +++ b/tools/install/claude.mjs @@ -0,0 +1,155 @@ +// 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"; + +import { + check, + claudeSource, + INSTALL_HINT, + isPlainCopy, + MARKETPLACE, + marketplaceCheck, + PLUGIN_ID, + pluginIntact, + readJson, + runCli, + updated, + writeAtomic, +} from "./common.mjs"; + +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"), {}); + 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]; + return { + 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)), + user, + stale: user ? records.filter((r) => isStale(r, user)) : [], + enabled: settings.enabledPlugins?.[PLUGIN_ID] === true, + }; +} + +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); + +// 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 }) { + const steps = []; + const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CLAUDE_CONFIG_DIR: home.dir }); + 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}`); + } + // 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) { + // `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`); + } + if (!read(home.dir).enabled) { + plugin("enable", PLUGIN_ID, "--scope", "user"); + steps.push("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, + [MARKETPLACE]: { ...declared, autoUpdate: true }, + }; + writeAtomic(join(home.dir, "settings.json"), JSON.stringify(state.settings, null, 2) + "\n"); + steps.push("turned on marketplace auto-update"); + } + steps.push(...syncScopedRecords(home.dir)); + return steps; +} + +// Claude Code reads ~/.claude/settings.json as the project settings of the +// home folder, so a session started there under another config home adds a +// project-scope record, and that record keeps the version it found. +// `claude plugin uninstall --scope project` would edit ~/.claude/settings.json, +// which belongs to the main home. So the record is brought to the user-scope +// install in place, after a backup. +export function syncScopedRecords(dir) { + const path = join(dir, RECORDS); + const { user, stale } = read(dir); + if (!stale.length) return []; + const backup = `${path}.${new Date().toISOString().replace(/[:.]/g, "-")}.bak`; + copyFileSync(path, backup, constants.COPYFILE_EXCL); + const data = readJson(path); + const steps = []; + for (const record of data.plugins[PLUGIN_ID].filter((r) => isStale(r, user))) { + steps.push(`moved the ${record.scope}-scope record for ${record.projectPath} from ${record.version} to ${user.version}`); + Object.assign(record, { + installPath: user.installPath, + version: user.version, + gitCommitSha: user.gitCommitSha, + lastUpdated: user.lastUpdated, + }); + } + writeAtomic(path, JSON.stringify(data, null, 2) + "\n"); + return [...steps, `backed up ${RECORDS} to ${backup}`]; +} + +export function doctorClaude(home, { version, skills, trusted }) { + const state = read(home.dir); + const installed = state.user?.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)) + : []; + return [ + marketplaceCheck(state.sources, trusted, `remove it with \`CLAUDE_CONFIG_DIR=${home.dir} claude plugin marketplace remove ${MARKETPLACE}\``), + check( + "installed", + installed === version && whole, + !installed + ? "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, + ), + check( + "no plain copies of plugin skills", + plain.length === 0, + plain.length ? plain.map((name) => join(skillsDir, name)).join(", ") : "none", + "delete these folders; the plugin ships the same skills", + ), + ]; +} diff --git a/tools/install/codex.mjs b/tools/install/codex.mjs new file mode 100644 index 00000000..1ebccab5 --- /dev/null +++ b/tools/install/codex.mjs @@ -0,0 +1,297 @@ +// Codex: the marketplace plugin in one CODEX_HOME, and trust for its hook. +// Codex runs a plugin hook only once `hooks.state."<key>".trusted_hash` in +// config.toml matches the hook's entry. Its TUI asks for that trust, but +// `codex app-server`, which T3 runs, never does. So the installer trusts the +// hook the way the TUI does, through the app-server, with the hash Codex +// reports, on every install and update, once its hooks file is the one +// tools/frozen-hooks.json pins. Only the hooks file is pinned, not the script +// 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. +import { spawn } from "node:child_process"; +import { closeSync, constants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { hookDigest } from "../plugin.mjs"; +import { + check, + codexSource, + INSTALL_HINT, + isPlainCopy, + launch, + MARKETPLACE, + marketplaceCheck, + neutralCwd, + PLUGIN_ID, + pluginIntact, + runCli, + updated, +} from "./common.mjs"; + +const REQUEST_TIMEOUT = 60_000; + +// A JSON-RPC client over `codex app-server` stdio. +export class AppServer { + static async start(home) { + const server = new AppServer(home); + await server.request("initialize", { clientInfo: { name: "t3-pstack-installer", version: "1" } }); + server.proc.stdin.write(JSON.stringify({ method: "initialized" }) + "\n"); + return server; + } + + constructor(home) { + const run = launch(home.bin, ["app-server"]); + this.proc = spawn(run.file, run.args, { + ...run.options, + cwd: neutralCwd(), + env: { ...process.env, CODEX_HOME: home.dir }, + stdio: ["pipe", "pipe", "ignore"], + }); + this.nextId = 0; + this.pending = new Map(); + this.notifications = []; + const failAll = (err) => { + for (const { reject, timer } of this.pending.values()) { + clearTimeout(timer); + reject(err); + } + this.pending.clear(); + }; + this.proc.on("error", failAll); + this.proc.stdin.on("error", failAll); + this.proc.on("exit", (code) => failAll(new Error(`codex app-server exited with ${code}`))); + let buffer = ""; + this.proc.stdout.on("data", (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + let message; + try { + message = line ? JSON.parse(line) : {}; + } catch { + // Not quoted: the line could hold anything the server printed. + failAll(new Error("codex app-server printed a line that is not JSON-RPC")); + this.close(); + return; + } + if (message.method) this.notifications.push(message); + const waiter = this.pending.get(message.id); + if (!waiter || message.method) continue; + this.pending.delete(message.id); + clearTimeout(waiter.timer); + if (message.error) waiter.reject(new Error(`${waiter.method}: ${JSON.stringify(message.error)}`)); + else waiter.resolve(message.result); + } + }); + } + + request(method, params) { + const id = ++this.nextId; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`${method}: no reply from codex app-server in ${REQUEST_TIMEOUT / 1000}s`)); + }, REQUEST_TIMEOUT); + this.pending.set(id, { resolve, reject, method, timer }); + this.proc.stdin.write(JSON.stringify({ id, method, params }) + "\n"); + }); + } + + // On Windows the process may be cmd.exe running npm's shim, and killing it + // leaves the server it started; closing stdin ends that server too. + close() { + this.proc.stdin.end(); + this.proc.kill(); + } +} + +async function withServer(home, use) { + const server = await AppServer.start(home); + try { + return await use(server); + } finally { + server.close(); + } +} + +// Codex keeps one cache directory per installed version and removes the old +// one when it installs another. +const cacheDir = (dir) => join(dir, "plugins/cache", MARKETPLACE, PLUGIN_ID.split("@")[0]); +function installedVersions(dir) { + const cache = cacheDir(dir); + return existsSync(cache) ? readdirSync(cache).filter((name) => !name.startsWith(".")) : []; +} + +const marketplaceSources = (config) => (config.marketplaces?.[MARKETPLACE] ? [codexSource(config.marketplaces[MARKETPLACE])] : []); + +async function pluginState(server, dir, skills) { + 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 versions = installedVersions(dir); + return { + sources: marketplaceSources(config), + installed: plugin?.installed === true, + enabled: plugin?.enabled === true, + versions, + intact: versions.length === 1 && pluginIntact(join(cacheDir(dir), versions[0]), ".codex-plugin/plugin.json", skills), + }; +} + +async function pluginHooks(server) { + const { data } = await server.request("hooks/list", {}); + return data.flatMap((entry) => entry.hooks).filter((hook) => hook.pluginId === PLUGIN_ID); +} + +// 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) { + if (!lstatSync(path, { throwIfNoEntry: false })?.isFile()) return null; + const fd = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)); + try { + return fstatSync(fd).isFile() ? readFileSync(fd, "utf8") : null; + } finally { + closeSync(fd); + } +} + +// The listed hook is an entry of the hooks file the Codex manifest names, read +// at the pinned digest. Codex hashes the file when it lists it, so the listed +// event, matcher, and command must be an entry of that text, with the plugin +// root for ${CLAUDE_PLUGIN_ROOT}, or the hash Codex reports is of other text. +function isPinned(hook, pin) { + const unix = (path) => path.replaceAll("\\", "/"); + if (!unix(hook.sourcePath).endsWith(`/${pin.path}`)) return false; + const text = readRegularFile(hook.sourcePath); + if (text === null || hookDigest(text) !== pin.sha256) return false; + const root = hook.sourcePath.slice(0, -pin.path.length - 1); + const entries = Object.entries(JSON.parse(text).hooks).flatMap(([event, groups]) => + groups.flatMap((group) => group.hooks.map((entry) => ({ event, matcher: group.matcher ?? null, entry }))), + ); + const expand = (line) => unix(line.replaceAll("${CLAUDE_PLUGIN_ROOT}", root)); + return entries.some( + ({ event, matcher, entry }) => + event[0].toLowerCase() + event.slice(1) === hook.eventName && + matcher === hook.matcher && + [entry.command, entry.commandWindows].some((line) => line !== undefined && expand(line) === unix(hook.command)), + ); +} + +// 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) { + 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"; + return null; +} + +export async function installCodex(home, { source, trusted, version, skills, codexHook }) { + const steps = []; + const plugin = (...args) => runCli(home.bin, ["plugin", ...args], { CODEX_HOME: home.dir }); + const state = () => withServer(home, (server) => pluginState(server, home.dir, skills)); + mkdirSync(home.dir, { recursive: true }); + let before = await state(); + if (!before.sources.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); + 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); + plugin("add", PLUGIN_ID); + const after = installedVersions(home.dir).join(", "); + steps.push( + { + install: `installed ${after}`, + update: updated(before.versions.join(", "), after, version), + repair: `reinstalled ${after}, whose files were missing`, + enable: "enabled it", + }[step], + ); + } + steps.push(...(await withServer(home, (server) => trustHooks(server, trusted, codexHook)))); + return steps; +} + +// One app-server session checks the marketplace source, lists the hooks, and +// writes the trust. The write names the config version the source check read, +// so Codex refuses it when config.toml changed in between. A config write +// also makes Codex read the hooks files again, so the listing after it shows +// 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) { + const { config, layers } = await server.request("config/read", { includeLayers: true }); + if (!marketplaceCheck(marketplaceSources(config), trusted).ok) 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), + ); + if (!pending.length) return []; + await server.request("config/value/write", { + keyPath: "hooks.state", + mergeStrategy: "upsert", + 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 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) { + await server.request("config/value/write", { keyPath: "hooks.state", mergeStrategy: "replace", value: user.config.hooks?.state ?? null }); + const status = (h) => `${h.key} ${now(h).trustStatus ?? "unlisted"}${same(h) ? "" : " with another entry"}`; + throw new Error(`Codex lists ${failed.map(status).join(", ")} after the trust write, so the installer took that trust back`); + } + return pending.map((h) => `trusted hook ${h.key} (was ${h.trustStatus})`); +} + +export async function doctorCodex(home, { version, skills, trusted, codexHook }) { + 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 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); + return [ + marketplaceCheck(state.sources, trusted, `remove it with \`CODEX_HOME=${home.dir} codex plugin marketplace remove ${MARKETPLACE}\``), + check( + "installed", + state.installed && installed === version && state.intact, + !state.installed + ? "not installed" + : state.versions.length && installed !== version + ? `${installed}, expected ${version}` + : state.intact + ? installed + : `its files in ${join(cacheDir(home.dir), installed)} are missing`, + INSTALL_HINT, + ), + check("enabled", state.enabled, state.enabled ? "yes" : "no", INSTALL_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, + ), + check( + "no plain copies of plugin skills", + plain.length === 0, + plain.length ? plain.slice(0, 5).join(", ") + (plain.length > 5 ? ` and ${plain.length - 5} more` : "") : "none", + "delete these copies, for example from ~/.agents/skills; the plugin ships the same skills", + ), + ]; + }); +} diff --git a/tools/install/common.mjs b/tools/install/common.mjs new file mode 100644 index 00000000..f78de9f9 --- /dev/null +++ b/tools/install/common.mjs @@ -0,0 +1,184 @@ +// What the Claude Code and Codex halves of the installer share: the plugin's +// ids, the marketplace sources it trusts, the CLI runner, file writes, and the +// shape of a doctor check. +import { execFileSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { chmodSync, chownSync, existsSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +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. +export const expandHome = (path, home) => (path === "~" ? home : /^~[/\\]/.test(path) ? join(home, path.slice(2)) : path); + +// JSON.parse's message quotes the text around the fault, which can hold a token. +export function readJson(path) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (err) { + throw err instanceof SyntaxError ? new Error(`${path}: invalid JSON`) : err; + } +} + +// A marketplace source as `{ key, label }`. The key is the same however the +// 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 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 }); + +// `--source`, read as both CLIs read it: `owner/repo` is GitHub, a URL or +// `user@host:path` is Git, and anything else is a directory, which must exist. +export function parseSource(text, home) { + if (/^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/.test(text)) return { ...github(text), arg: text }; + if (/^[a-z][a-z0-9+.-]*:\/\/|^[^/\\\s]+@[^/\\\s]+:/i.test(text)) return { ...gitUrl(text), arg: text }; + const path = resolve(expandHome(text, home)); + if (!existsSync(path)) throw new Error(`--source ${text}: not owner/repo, a Git URL, or an existing directory`); + return { ...directory(path), arg: path, local: path }; +} + +// 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) { + if (source.source === "github") return github(source.repo); + if (source.source === "git") return gitUrl(source.url); + if (source.source === "directory") return directory(source.path); + return { key: JSON.stringify(source), label: JSON.stringify(source) }; +} + +export function codexSource(entry) { + if (entry.source_type === "git") return gitUrl(entry.source); + if (entry.source_type === "local") return directory(entry.source); + return { key: JSON.stringify(entry), label: JSON.stringify(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); + 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}`, + ); +} + +// A plain copy of one of the plugin's skills: its name and its SKILL.md +// match. A user's own skill that only shares the name is theirs. +export const isPlainCopy = (name, skillFile, skills) => + skills.has(name) && existsSync(skillFile) && readFileSync(skillFile, "utf8") === skills.get(name); + +// An install whose recorded version still has its files: the manifest and +// each skill's SKILL.md. +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 `<cwd>/.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) { + neutral = mkdtempSync(join(tmpdir(), "t3-pstack-")); + process.on("exit", () => rmSync(neutral, { recursive: true, force: true })); + } + return neutral; +} + +// The file `bin` runs: `bin` itself when it is a path, else its first match +// on PATH. Windows, like cmd.exe, tries PATHEXT's extensions on a name that +// has none, and so skips the extensionless sh script npm installs beside a +// .cmd shim. +export function resolveCli(bin) { + if (basename(bin) !== bin) return existsSync(bin) ? bin : undefined; + const extensions = process.platform === "win32" && !extname(bin) ? (process.env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD").split(";") : [""]; + for (const dir of (process.env.PATH ?? "").split(delimiter)) { + for (const ext of extensions) { + if (dir && existsSync(join(dir, bin + ext))) return join(dir, bin + ext); + } + } +} + +// Looked up rather than run: `codex --version` already writes into the +// default CODEX_HOME. +export const hasCli = (bin) => resolveCli(bin) !== undefined; + +// cmd.exe's line for a .cmd or .bat file, as cross-spawn builds it. Each +// argument is quoted for the program's own parser, then every cmd.exe +// metacharacter is escaped twice: once for `cmd /c`, and once for the line in +// the script, such as npm's shim, that passes `%*` on. +const CMD_META = /([()\][%!^"`<>&|;, *?])/g; +function cmdLine(file, args) { + const quote = (arg) => + `"${arg.replace(/(\\*)"/g, '$1$1\\"').replace(/(\\*)$/, "$1$1")}"`.replace(CMD_META, "^$1").replace(CMD_META, "^$1"); + return [file.replace(CMD_META, "^$1"), ...args.map(quote)].join(" "); +} + +// How to start `bin`. Node starts a .cmd or .bat file only through cmd.exe, +// and an npm-installed `claude` or `codex` on Windows is one. +export function launch(bin, args) { + const file = resolveCli(bin) ?? bin; + if (process.platform !== "win32" || !/\.(cmd|bat)$/i.test(file)) return { file, args, options: {} }; + return { + file: process.env.comspec || "cmd.exe", + args: ["/d", "/s", "/c", `"${cmdLine(file, args)}"`], + options: { windowsVerbatimArguments: true }, + }; +} + +export function runCli(bin, args, env) { + const run = launch(bin, args); + try { + return execFileSync(run.file, run.args, { + ...run.options, + cwd: neutralCwd(), + env: { ...process.env, ...env }, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 300_000, + }); + } catch (err) { + throw new Error(`${bin} ${args.join(" ")}: ${(err.stderr || err.stdout || err.message).trim()}`); + } +} + +// A path beside `path` that nothing holds. Creating it with the `wx` flag or +// symlinkSync fails rather than replace a file someone else put there. +export const tempBeside = (path) => join(dirname(path), `.${basename(path)}.${randomUUID()}.t3-pstack.tmp`); + +// A crash between the write and the rename leaves the old file whole. The +// rename lands on the symlink's target, so a settings file one home links +// from another stays shared. The new file takes the mode and, where the +// system allows, the owner of the file it replaces, or of `like`. +export function writeAtomic(path, text, like = path) { + const target = existsSync(path) ? realpathSync(path) : path; + const tmp = tempBeside(target); + writeFileSync(tmp, text, { flag: "wx" }); + const old = statSync(like, { throwIfNoEntry: false }); + if (old) { + chmodSync(tmp, old.mode & 0o7777); + try { + chownSync(tmp, old.uid, old.gid); + } catch (err) { + if (err.code !== "EPERM") throw err; + } + } + renameSync(tmp, target); +} + +// An update step. A marketplace behind the installer, such as main before a +// release merges, leaves the version where it was. +export const updated = (from, to, version) => (from === to ? `the marketplace has no ${version}, so it stays at ${to}` : `updated ${from} to ${to}`); + +// A doctor check. `warn` marks one that never fails the run. +export const check = (name, ok, detail, fix, warn = false) => ({ name, ok, detail, ...(ok ? {} : { fix }), ...(warn && { warn }) }); diff --git a/tools/install/homes.mjs b/tools/install/homes.mjs new file mode 100644 index 00000000..e9d8545f --- /dev/null +++ b/tools/install/homes.mjs @@ -0,0 +1,78 @@ +// Every Claude Code and Codex config home the installer serves: the defaults, +// the shell's CLAUDE_CONFIG_DIR and CODEX_HOME, each claudeAgent and codex +// instance in T3's settings, enabled or not, and the homes named by flag. +// An instance's home resolves as T3 resolves it (apps/server +// Drivers/ClaudeHome.ts and Drivers/CodexHomeLayout.ts): `config.homePath`, +// else the instance's CLAUDE_CONFIG_DIR or CODEX_HOME, else the default. T3 +// expands a leading `~` in the first two and in `config.binaryPath` +// (provider-core pathExpansion.ts). +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join, resolve } from "node:path"; + +import { expandHome as expand } from "./common.mjs"; + +const DRIVERS = { claudeAgent: "claude", codex: "codex" }; +const ENV_VAR = { claude: "CLAUDE_CONFIG_DIR", codex: "CODEX_HOME" }; +const DEFAULT_HOME = { claude: ".claude", codex: ".codex" }; + +// T3 keeps its state in `${T3CODE_HOME:-~/.t3}/userdata` (apps/server os-jank.ts and config.ts). +export function t3SettingsPath(env, home) { + const base = env.T3CODE_HOME?.trim() ? resolve(expand(env.T3CODE_HOME.trim(), home)) : join(home, ".t3"); + return join(base, "userdata", "settings.json"); +} + +function readT3Settings(path) { + try { + return { instances: JSON.parse(readFileSync(path, "utf8")).providerInstances ?? {} }; + } catch (err) { + // JSON.parse's message quotes the text around the fault, which can hold a token. + return { instances: {}, error: err.code === "ENOENT" ? "not found" : err instanceof SyntaxError ? "invalid JSON" : err.message }; + } +} + +// Returns `{ homes, settings }`. A home is `{ harness, dir, from, bin }`: +// `from` lists where it was found, and `bin` is the CLI T3 runs for it. +export function discoverHomes({ env, home, claudeHomes = [], codexHomes = [] }) { + const homes = new Map(); + const add = (harness, dir, from, bin) => { + const key = `${harness}\0${existsSync(dir) ? realpathSync(dir) : dir}`; + const entry = homes.get(key) ?? { harness, dir, from: [], bin: harness }; + entry.from.push(from); + if (bin && entry.bin === harness) entry.bin = bin; + homes.set(key, entry); + }; + + for (const harness of ["claude", "codex"]) add(harness, join(home, DEFAULT_HOME[harness]), "default"); + + const settingsPath = t3SettingsPath(env, home); + const { instances, error } = readT3Settings(settingsPath); + // A Codex shadow home is T3's auth overlay: its config.toml, plugins, and + // skills are symlinks into the shared home, which is the one to install into. + const shadows = new Set(); + for (const [id, instance] of Object.entries(instances)) { + const harness = DRIVERS[instance.driver]; + if (!harness) continue; + const config = instance.config ?? {}; + const instanceEnv = Object.fromEntries((instance.environment ?? []).map((v) => [v.name, v.value ?? ""])); + if (config.shadowHomePath?.trim()) shadows.add(resolve(expand(config.shadowHomePath.trim(), home))); + const fromEnv = instanceEnv[ENV_VAR[harness]]?.trim(); + const dir = config.homePath?.trim() + ? resolve(expand(config.homePath.trim(), home)) + : fromEnv + ? resolve(expand(fromEnv, home)) + : join(home, DEFAULT_HOME[harness]); + const disabled = instance.enabled === false || config.enabled === false; + const bin = config.binaryPath?.trim(); + add(harness, dir, `T3 instance ${id}${disabled ? " (disabled)" : ""}`, bin && expand(bin, home)); + } + + for (const harness of ["claude", "codex"]) { + const dir = env[ENV_VAR[harness]]?.trim(); + if (dir && !shadows.has(resolve(dir))) add(harness, resolve(dir), ENV_VAR[harness]); + } + for (const dir of claudeHomes) add("claude", resolve(expand(dir, home)), "--claude-home"); + for (const dir of codexHomes) add("codex", resolve(expand(dir, home)), "--codex-home"); + + const counted = Object.values(instances).filter((instance) => DRIVERS[instance.driver]).length; + return { homes: [...homes.values()], settings: { path: settingsPath, instances: counted, error } }; +} diff --git a/tools/install/sheet.mjs b/tools/install/sheet.mjs new file mode 100644 index 00000000..94990f9f --- /dev/null +++ b/tools/install/sheet.mjs @@ -0,0 +1,102 @@ +// The model sheet. Provider instance IDs differ per machine, not per harness, +// so since 2.0.0-rc.3 there is one sheet per machine, at +// `${XDG_CONFIG_HOME:-~/.config}/t3-pstack/models.md`. Released versions read +// `t3-pstack-models.md` in each config home, so the installer moves that sheet +// to the machine path once and leaves a symlink at each home's old path. +import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, symlinkSync } from "node:fs"; +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"; + +export const HOME_SHEET = "t3-pstack-models.md"; +const SETUP_HINT = "run /t3-pstack:setup-pstack in a T3 thread"; + +export const sheetPath = (env, home) => + join(env.XDG_CONFIG_HOME?.trim() ? resolve(env.XDG_CONFIG_HOME.trim()) : join(home, ".config"), "t3-pstack", "models.md"); + +const isFile = (path) => statSync(path, { throwIfNoEntry: false })?.isFile() ?? false; +const sameBytes = (a, b) => readFileSync(a).equals(readFileSync(b)); +const resolvesTo = (path, target) => isFile(path) && isFile(target) && realpathSync(path) === realpathSync(target); +const homeSheets = (homes) => homes.map((home) => join(home.dir, HOME_SHEET)); + +function link(target, path) { + const tmp = tempBeside(path); + symlinkSync(target, tmp); + renameSync(tmp, path); +} + +// A sheet a home links to from outside every home, such as one kept in a +// dotfiles repository, is the user's file to keep. +function outsideHomes(path, homes) { + return homes.every(({ dir }) => { + const rel = relative(existsSync(dir) ? realpathSync(dir) : resolve(dir), path); + return rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel); + }); +} + +export function moveSheet(sheet, homes) { + const steps = []; + if (!isFile(sheet)) { + const found = [...new Set(homeSheets(homes).filter(isFile).map((path) => realpathSync(path)))]; + if (!found.length) return steps; + if (new Set(found.map((path) => readFileSync(path, "utf8"))).size > 1) return steps; + mkdirSync(dirname(sheet), { recursive: true }); + const kept = found.find((path) => outsideHomes(path, homes)); + if (kept) { + link(kept, sheet); + steps.push(`linked the sheet to ${kept}`); + } else { + writeAtomic(sheet, readFileSync(found[0]), found[0]); + steps.push(`moved the sheet from ${found[0]}`); + } + } + // A home sheet that differs from the machine sheet stays, and doctor names + // it. A link to another file stays too, so that file is never orphaned. + for (const path of homeSheets(homes)) { + if (!existsSync(dirname(path)) || resolvesTo(path, sheet)) continue; + const present = lstatSync(path, { throwIfNoEntry: false }); + if (present && isFile(path) && (present.isSymbolicLink() || !sameBytes(path, sheet))) continue; + link(sheet, path); + steps.push(`linked ${path} to it`); + } + return steps; +} + +export function doctorSheet(sheet, homes, { catalog, roles, tiers }) { + const old = homeSheets(homes).filter(isFile); + if (!isFile(sheet)) { + const differ = new Set(old.map((path) => readFileSync(path, "utf8"))).size > 1; + if (old.length) { + return [ + check( + "machine sheet", + false, + `missing; ${old.join(", ")} ${differ ? "differ, so the installer leaves them" : "not moved yet"}`, + differ ? `keep one, then ${INSTALL_HINT}` : INSTALL_HINT, + ), + ]; + } + return [check("machine sheet", false, "none, so every role runs on its skill's defaults", SETUP_HINT, true)]; + } + const problems = checkSheet(parseSheet(readFileSync(sheet, "utf8")), { roles, defaults: tiers, catalog }); + const shadowing = old.filter((path) => !resolvesTo(path, sheet) && !sameBytes(path, sheet)); + return [ + check( + "machine sheet valid", + problems.length === 0, + problems.length + ? problems.join("; ") + : catalog + ? "every line parses and dispatches on the catalog" + : "every line parses (pass --catalog to check targets)", + SETUP_HINT, + ), + check( + "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`, + ), + ]; +} diff --git a/tools/plugin.mjs b/tools/plugin.mjs index 87baad5b..4f54511c 100644 --- a/tools/plugin.mjs +++ b/tools/plugin.mjs @@ -1,8 +1,15 @@ -// Where the plugin lives in the repo, and the Markdown code spans the generator -// and the runtime table render identifiers with. +// Where the plugin lives in the repo, how its hooks files are pinned, and the +// Markdown code spans the generator and the runtime table render identifiers +// with. The installer imports this file, so it imports only Node. +import { createHash } from "node:crypto"; export const PLUGIN = "plugins/pstack"; export const SKILLS = `${PLUGIN}/skills`; +// Each hooks file's digest and the release that last changed it. A hooks +// file's digest ignores its formatting. +export const FROZEN_HOOKS = "tools/frozen-hooks.json"; +export const hookDigest = (text) => createHash("sha256").update(JSON.stringify(JSON.parse(text))).digest("hex"); + export const code = (s) => `\`${s}\``; export const codeList = (models) => models.map(code).join(", "); diff --git a/tools/substitutions.json b/tools/substitutions.json index e6e83042..f0f4de10 100644 --- a/tools/substitutions.json +++ b/tools/substitutions.json @@ -14,7 +14,7 @@ }, { "pattern": "- No `~/.cursor/rules/pstack-models.mdc` means `/setup-pstack` hasn't run for this user", - "replacement": "- No model sheet (`t3-pstack-models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user", + "replacement": "- No model sheet (`~/.config/t3-pstack/models.md`, see `/setup-pstack`) means `/setup-pstack` hasn't run for this user", "rationale": "the T3 sheet replaces the Cursor rule (The sheet)", "files": "^skills/poteto-help/SKILL\\.md$" },