From 54563132237fb3e429a2a3f022005de3479610b2 Mon Sep 17 00:00:00 2001 From: Andy Chan <34787960+ndisisnd@users.noreply.github.com> Date: Sun, 9 Aug 2026 02:15:30 +0800 Subject: [PATCH] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor(install):=20cance?= =?UTF-8?q?l=20npm=20package;=20install.sh=20becomes=20the=20one=20install?= =?UTF-8?q?er=20for=20both=20hosts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - install.sh absorbs the Codex wiring from bin/wdym-prompt.js: --claude/ --codex/--both host flags, symlinked ~/.agents skill path with copy fallback (--copy), hooks.json wiring with dedupe, AGENTS.md contract, trust notice last on every Codex-touching run; Codex stays global-only - bin/wdym-prompt.js and package.json deleted; npx wdym-prompt was never published (registry 404s), so nothing external breaks - README/ARCHITECTURE/llms.txt/RELEASES swept clean of the npm story; npx skills add stays documented as the file-delivery route - Review fixes: agents/openai.yaml now ships in the skill copy (Codex manifest was silently missing), duplicate contract removal closes its gap, RELEASES.md v1.2.0 section corrected - Verified: 78-assertion port harness + 30 adversarial assertions in sandbox HOMEs; contract blocks written by the old npx installer are recognized as already-current; detect bench green Co-Authored-By: Claude Fable 5 --- .gitignore | 2 - ARCHITECTURE.md | 18 +- CHANGELOG.md | 16 + README.md | 112 +++---- RELEASES.md | 26 +- bin/wdym-prompt.js | 814 --------------------------------------------- install.sh | 687 +++++++++++++++++++++++++++----------- llms.txt | 2 +- package.json | 48 --- 9 files changed, 575 insertions(+), 1150 deletions(-) delete mode 100644 bin/wdym-prompt.js delete mode 100644 package.json diff --git a/.gitignore b/.gitignore index c7569a6..a005620 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,3 @@ audit/ .claude/.headroom_wrap_marker.json .tokensave improve/ -node_modules/ -*.tgz diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 459be30..7607766 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -84,12 +84,9 @@ plumbing only — see [Two-host model](#two-host-model). wdym/ ├── SKILL.md Thin dispatcher → refs/protocol.md ├── pref.json Default mode template -├── package.json npm package `wdym-prompt` (bin, files allowlist) -├── install.sh Shell installer — Claude Code only, frozen +├── install.sh The installer — both hosts, host/scope/activation flags ├── agents/ │ └── openai.yaml Codex skill manifest (display name, invocation policy) -├── bin/ -│ └── wdym-prompt.js npx installer — both hosts, no dependencies ├── hooks/ │ ├── prompt-detect.py UserPromptSubmit pre-scorer │ └── telemetry-stats.py --status report renderer @@ -144,7 +141,7 @@ text. An override-shaped imperative there (`ACTION: invoke … BEFORE processing a prompt injection and was sometimes refused, silently skipping the skill. So the block carries only a neutral classification *signal* — host-neutral wording, naming no host's files — while the invoke instruction lives in the host's own instruction file, which the -model treats as trusted user configuration. Both installers write a marker-delimited, +model treats as trusted user configuration. `install.sh` writes a marker-delimited, idempotent contract there at the install scope: `~/.claude/CLAUDE.md` or the local project root on Claude Code, `$CODEX_HOME/AGENTS.md` on Codex. A fresh install therefore carries the same authority the dev repo does rather than leaving the block to fend for itself. @@ -172,8 +169,8 @@ hook file, and the ask step — and those are the four the README's diagram note same tree there as a symlink rather than copying it. Two independent copies would drift the moment one host updated and the other didn't, and drift in a skill body is invisible until behaviour diverges. Where symlinks are unavailable (or `--copy` is passed) the installer -copies instead and records which it did in `pref.json` as `codex_skill_mode`, so -`--doctor` knows whether it needs to check the two paths for drift. +copies instead and records which it did in `pref.json` as `codex_skill_mode`, so a later +run knows whether it has two paths to keep in step. **One pref file, both hosts.** `~/.claude/wdym/pref.json` is canonical for Claude Code and Codex alike, even though the rest of the Codex wiring lives under `~/.codex`. Splitting it @@ -193,10 +190,9 @@ contents, so any install, update, or re-run invalidates the previous approval an goes silent until the user runs `/hooks` in Codex and approves it again — with no warning that it stopped firing. Codex also records approval outside any file this tool can read, so there is neither a programmatic approval path nor a readable trust state. Both are handled -by documentation loudness instead: the installer prints an action-required notice last on -every Codex-touching run, `--doctor` reports trust as `unknown` and points at `/hooks`, -`refs/heal.md` knows the "wired but silent" failure mode, and the README repeats it in the -Codex notes. +by documentation loudness instead: `install.sh` prints an action-required notice last on +every Codex-touching run, `refs/heal.md` knows the "wired but silent" failure mode, and the +README repeats it in the Codex notes. **Codex is global-scope only.** A repo-scoped Codex hook would live in a committed file, so every teammate pulling the repo would get an approval prompt for a tool they never diff --git a/CHANGELOG.md b/CHANGELOG.md index d9c39d9..4032daa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project will be documented here. ## 2026-08-09 +### [15] — One installer, no npm package: `install.sh` absorbs Codex + +- `install.sh`: now installs and wires wdym on either host or both. New host flags `--claude` / `--codex` / `--both`, defaulting to whichever of `~/.claude` and `~/.codex` is present; new `--copy`; `--global` / `--local` / `--hook` / `--on-demand` / `--dir` / `--tarball` / `--force` unchanged +- `install.sh`: Codex wiring is global scope only — canonical skill copy stays at `~/.claude/skills/wdym` and is exposed to Codex at `~/.agents/skills/wdym` as a symlink, falling back to a real copy automatically when the symlink is refused (`--copy` forces one) and recording which in `pref.json` as `codex_skill_mode`; hook entry goes into `$CODEX_HOME/hooks.json`, trust contract into `$CODEX_HOME/AGENTS.md` with the `$wdym` command prefix +- `install.sh`: `--codex --local` exits 2 and writes nothing; `--both --local` warns and installs Claude Code at local scope only; a Codex-touching run ends with the hook re-approval notice (action, reason, failure mode, `$wdym --status` confirmation), never printed on a Claude-only run +- `install.sh`: hook wiring now matches on `prompt-detect.py` content on both hosts, collapses duplicate entries to one, refuses a settings file that parses to a non-object, and writes only when bytes change so re-runs are byte-idempotent; trust-contract writer takes the host command prefix and drops duplicate marker blocks +- `install.sh`: the installed skill set now includes `agents/`, so `agents/openai.yaml` reaches Codex — without it Codex has no display name and no `allow_implicit_invocation`, and never invokes the skill on its own +- `install.sh`: removing a duplicate trust-contract block now closes the gap it leaves instead of stranding its blank lines, so re-runs over a hand-pasted duplicate stop growing the file +- `bin/wdym-prompt.js`, `package.json`: deleted — the npm package was cancelled before publication, so the shell installer is the only installer +- `.gitignore`: dropped the `node_modules/` and `*.tgz` pack-artifact lines +- `README.md`: install section is two options again — `install.sh` (flag table now covers `--claude` / `--codex` / `--both` / `--copy`) and `npx skills add` for file delivery plus `/wdym --init`; comparison table loses the npm column and keeps the Codex facts; Codex requirements note, update section and FAQ repointed at `install.sh` and `/wdym --status` +- `ARCHITECTURE.md`: file map drops `bin/wdym-prompt.js` and `package.json`; two-host notes name `install.sh` as the installer and no longer reference a `--doctor` command +- `llms.txt`: index entry for `bin/wdym-prompt.js` replaced with `install.sh` +- `RELEASES.md`: v1.2.0 notes repointed at `install.sh` — the `npx wdym-prompt`, `--doctor` and `--uninstall` headline items are gone, `--copy` takes their place, and the "shell installer is frozen, Claude Code only" limitation is replaced by the `npx skills add` one +- `improve/v1.2-test.md`: dropped T3 (Node 18) and T5 (npm publish preflight); T1 installs with `./install.sh --codex` + ### [14] — Both installers write the same trust contract, and the live checks have a script - `install.sh`: contract line is now host-neutral ("invoke the `wdym` skill" — no "via the Skill tool"), byte-identical to what `bin/wdym-prompt.js` writes, so alternating installers no longer rewrite one line back and forth; verified in a sandbox that a stale block is refreshed in place and a re-run reports already-current diff --git a/README.md b/README.md index dff7c8c..37b8c31 100644 --- a/README.md +++ b/README.md @@ -57,28 +57,33 @@ after that it's automatic. ## Installation -Three ways in. The npx installer is the only one that covers both hosts and finishes the -job in one command; the other two exist for people already committed to a particular tool. +Two ways in. The installer is the one that finishes the job — both hosts, hook, trust +contract, prefs, in one command. The second option is for people already managing skills +with the skills CLI. -### Option 1 — `npx wdym-prompt` _(recommended)_ +### Option 1 — the installer _(recommended)_ ```bash -npx wdym-prompt +curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash ``` That is the whole install. It detects which hosts you have — `~/.claude`, `~/.codex`, or both — lays down the skill files, wires the `UserPromptSubmit` hook, writes the trust contract into the host's instruction file, and creates your `pref.json`. **Global is the -default**, so wdym fires in every project you touch. +default**, so wdym fires in every project you touch. It pulls a tarball into a temp dir — +no clone, nothing left behind. -Say so explicitly when the defaults aren't what you want: +Say so explicitly when the defaults aren't what you want. Piped into `bash`, flags go after +`-s --`: ```bash -npx wdym-prompt --both # Claude Code and Codex -npx wdym-prompt --claude --local # this project only (Claude Code) -npx wdym-prompt --on-demand # install inert; runs only via /wdym +curl -fsSL .../install.sh | bash -s -- --both # Claude Code and Codex +curl -fsSL .../install.sh | bash -s -- --claude --local # this project only (Claude Code) +curl -fsSL .../install.sh | bash -s -- --on-demand # install inert; runs only via /wdym ``` +Or with the script on disk: `./install.sh --both`. + | Flag | What it does | |---|---| | `--claude` / `--codex` / `--both` | Pick the host. Default: whichever of `~/.claude` and `~/.codex` exists | @@ -88,14 +93,16 @@ npx wdym-prompt --on-demand # install inert; runs only via /wdym | `--hook` | Fire on every prompt — the default | | `--on-demand` | Install inert; run only via `/wdym`, no hook wired | | `--copy` | Copy the skill for Codex instead of symlinking it | -| `--doctor` | Report what is installed, wired, and in sync | -| `--uninstall` | Remove skill dirs, hook entries, and contract blocks | -| `--force` | Skip the guards (source-repo check, prefs on uninstall) | +| `--tarball ` | Install from a specific tarball instead of `main` | +| `--force` | Skip the "you're inside the wdym repo" guard | Local always wins over global when both exist, so you can install globally and still override one repo. Re-run the installer any time — it is idempotent, keeps your `pref.json` and telemetry, and collapses a duplicated hook entry back to one if an earlier install left -two behind. It needs Node 18 or newer and installs no dependencies. +two behind. It needs `python3`, `tar`, and either `curl` or `wget`. + +Already have the files on disk and just want to rewire? `/wdym --init` does the init half +on its own. #### Installing for Codex @@ -116,37 +123,7 @@ Four things worth knowing, none of them blockers: Codex users type `$wdym` where this README writes `/wdym`. Everything else is identical. -### Option 2 — the shell installer _(Claude Code only)_ - -`install.sh` is frozen at what it does today: Claude Code, no Codex support, no new flags. -It stays fully supported for anyone already using it. - -```bash -curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -``` - -It pulls a tarball into a temp dir — no clone, nothing left behind. Global by default; add -`-s -- --local` for one project: - -```bash -curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -s -- --local -``` - -| Flag | What it does | -|---|---| -| _(none)_ | Global install into `~/.claude` — the default | -| `--local` | Local install into `./.claude` for this project | -| `--hook` | Fire on every prompt — the default | -| `--on-demand` | Install inert; run only via `/wdym`, no hook wired | -| `--dir ` | Local install into another project | -| `--tarball ` | Install from a specific tarball instead of `main` | -| `--force` | Skip the "you're inside the wdym repo" guard | - -Like the npx installer it writes your `pref.json`, wires the detector, and is safe to -re-run. Already have the files on disk and just want to rewire? `/wdym --init` does the -init half on its own. - -### Option 3 — `npx skills add` +### Option 2 — `npx skills add` If you already manage skills with the [skills CLI](https://github.com/vercel-labs/skills), wdym installs like any other package: @@ -172,26 +149,27 @@ skill that only answers to `/wdym` and never fires on its own. ### Which one to use -| | `npx wdym-prompt` | `install.sh` | `npx skills add` | -|---|---|---|---| -| Claude Code | ✅ | ✅ | files only | -| Codex | ✅ | ❌ frozen, never supported it | ❌ use `npx wdym-prompt` | -| Skill files | ✅ | ✅ | ✅ | -| `pref.json`, hook, trust contract | ✅ | ✅ Claude Code only | via `/wdym --init` | -| Updates | re-run it | re-run it | `npx skills update` | -| What lands in the skill dir | curated runtime files only | curated runtime files only | the whole repo, `install.sh` and `tests/` included | -| Also writes | nothing else | nothing else | `skills-lock.json` at your project root | - -All three land the skill in the same place — `.claude/skills/wdym` for project scope, +| | `install.sh` | `npx skills add` | +|---|---|---| +| Claude Code | ✅ | files only | +| Codex | ✅ global scope only | ❌ use `install.sh --codex` | +| Skill files | ✅ | ✅ | +| `pref.json`, hook, trust contract | ✅ | via `/wdym --init` | +| Updates | re-run it | `npx skills update` | +| What lands in the skill dir | curated runtime files only | the whole repo, `install.sh` and `tests/` included | +| Also writes | nothing else | `skills-lock.json` at your project root | + +Both land the skill in the same place — `.claude/skills/wdym` for project scope, `~/.claude/skills/wdym` for global — so pick whichever fits your workflow. `/wdym --init` -is idempotent across all of them. +is idempotent across both. ### Confirming it works To confirm it's wired up, submit any normal prompt: in the default mode you'll see the -rewrite and a gate before it runs. `npx wdym-prompt --doctor` checks the same thing from -the outside — which hosts are wired, and on Codex it reminds you to confirm hook approval -with `/hooks`, which only Codex itself can tell you. +rewrite and a gate before it runs. `/wdym --status` reports the same thing from inside the +session — scope, activation, and where the files came from. On Codex, run `$wdym --status` +after approving the hook with `/hooks`; hook approval is the one thing only Codex itself +can confirm. ## How it works @@ -269,12 +247,11 @@ these into a prompt _(or run them standalone)_: ## How to update -**Update wdym itself** by re-running the installer you used. Both are idempotent and -neither touches your `pref.json` or telemetry: +**Update wdym itself** by re-running the installer. It is idempotent and never touches your +`pref.json` or telemetry: ```bash -npx wdym-prompt # either host -curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash # Claude Code +curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash ``` **If you installed with `npx skills add`**, update through the same CLI: @@ -291,9 +268,10 @@ again, or it goes quiet without saying so. hook came unwired, or after a `skills update` — with `/wdym --init`, which redoes the init half without reinstalling. -**Check what's actually wired** with `npx wdym-prompt --doctor`: it reports each host, the -skill copies, and whether they are in sync. Codex hook approval is the one thing it can't -read — it lives inside Codex, so `--doctor` points you at `/hooks` to confirm it. +**Check what's actually wired** with `/wdym --status` (`$wdym --status` on Codex): it +reports the resolved scope, activation, and run mode from inside the session. Codex hook +approval is the one thing it can't read — that lives inside Codex, so confirm it with +`/hooks` there. ## FAQ @@ -317,7 +295,7 @@ leaves your machine. `--local` when you want it scoped to one repo; a local install always wins over the global one, so you can do both. Codex is global-only — see the Codex notes under Installation. -**Does it work with Codex?** Yes, from v1.2.0. `npx wdym-prompt --codex` (or `--both`) +**Does it work with Codex?** Yes, from v1.2.0. `./install.sh --codex` (or `--both`) wires it: the skill is exposed at `~/.agents/skills/wdym`, the hook goes into `~/.codex/hooks.json`, and the trust contract into `~/.codex/AGENTS.md`. One manual step remains — approve the hook with `/hooks` inside Codex after every install or update. diff --git a/RELEASES.md b/RELEASES.md index 06440f1..d358cdb 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -5,21 +5,19 @@ What's new for you, release by release. ## v1.2.0 — 2026-08-09 > wdym is no longer a Claude Code tool. It runs on Codex too, and one command — -> `npx wdym-prompt` — now installs and wires it on either host, or both at once. The README +> `install.sh` — now installs and wires it on either host, or both at once. The README > finally shows you the whole flow in a single picture instead of describing it in prose. ### ✨ New - **Codex support.** wdym classifies, rewrites, and gates prompts in Codex the same way it - does in Claude Code. Install it with `npx wdym-prompt --codex`, or `--both` to cover both + does in Claude Code. Install it with `./install.sh --codex`, or `--both` to cover both hosts at once. -- **One install command.** `npx wdym-prompt` detects which hosts you have, lays down the +- **One install command.** The installer detects which hosts you have, lays down the skill, wires the hook, writes the trust contract, and creates your preferences — no follow-up step. Re-run it any time to update. -- **`--doctor`.** Ask wdym what is actually installed: which hosts are wired and whether - the two skill copies agree. Codex keeps hook approval to itself, so `--doctor` reminds - you to confirm it with `/hooks` rather than pretending to know. -- **`--uninstall`.** Removes the skill, the hook entry, and the contract block, and leaves - your preferences and usage history alone unless you pass `--force`. +- **`--copy`.** Codex normally reads the same skill copy Claude Code does, through a link. + Where links are unavailable, the installer falls back to a real copy on its own; `--copy` + forces one. - **A flow diagram in the README.** One picture covering both activation modes, the prompts that pass straight through, and both run modes — with a short table naming the four things that differ between Claude Code and Codex. @@ -32,10 +30,10 @@ What's new for you, release by release. Claude Code and Codex. - Your Codex copy of the skill is a link to the Claude Code copy, so updating one updates the other and the two cannot quietly drift apart. -- Re-running an installer over an install that ended up with two wdym hooks now collapses +- Re-running the installer over an install that ended up with two wdym hooks now collapses them back to one, which stops the double rewrite and double usage counting that caused. -- Both installers now write the exact same trust contract, so switching between - `npx wdym-prompt` and the shell installer no longer rewrites one line back and forth. +- The installer only writes a file when its contents actually change, so re-running it over + a healthy install is a genuine no-op. ### 🐛 Fixed - Launching your agent inside a subfolder of a project no longer misses that project's @@ -53,9 +51,9 @@ What's new for you, release by release. you will see a one-line signal above each answer. Cosmetic only. - **Codex is global-only.** wdym applies to every project there; per-repo Codex installs are deliberately refused. -- **The shell installer is frozen and stays Claude Code only.** `curl … install.sh` keeps - working exactly as it did; it will not gain Codex support. Use `npx wdym-prompt` for - that. +- **`npx skills add` still only delivers files.** It copies the skill in but wires nothing, + so follow it with `/wdym --init` to get the hook, the preferences, and the trust + contract. ## v1.1.0 — 2026-07-30 diff --git a/bin/wdym-prompt.js b/bin/wdym-prompt.js deleted file mode 100644 index 9b2bd21..0000000 --- a/bin/wdym-prompt.js +++ /dev/null @@ -1,814 +0,0 @@ -#!/usr/bin/env node -'use strict'; -/* - * wdym-prompt — the npx installer for the wdym skill. - * - * Installs and wires wdym on either agent host: - * - * Claude Code skill dir + settings.json/settings.local.json hook + CLAUDE.md - * trust anchor + pref.json. Local or global scope. - * Codex canonical skill copy under ~/.claude/skills/wdym, exposed to - * Codex at ~/.agents/skills/wdym (symlink, copy on fallback), - * hook entry in $CODEX_HOME/hooks.json, trust anchor in - * $CODEX_HOME/AGENTS.md. Global scope only. - * - * One pref file serves both hosts (~/.claude/wdym/pref.json at global scope), so - * the two can never disagree about mode or activation. - * - * Zero runtime dependencies. Node >= 18. - */ - -const fs = require('fs'); -const path = require('path'); -const os = require('os'); - -// --- Constants --------------------------------------------------------------- - -const PKG_ROOT = path.resolve(__dirname, '..'); -const PKG = JSON.parse(fs.readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf8')); -const SKILL_NAME = process.env.SKILL_NAME || 'wdym'; - -// Marker strings are shared with install.sh so either installer recognises (and -// refreshes in place) a block the other one wrote. -const MARK_START = ''; -const MARK_END = ''; - -// Files that constitute the skill. Verified in the package before anything is -// written, so a broken publish cannot half-install over a working copy. -const REQUIRED = [ - 'SKILL.md', - 'hooks/prompt-detect.py', - 'hooks/telemetry-stats.py', - 'refs/manifest.json', - 'refs/protocol.md', - 'refs/commands.md', - 'refs/help.txt', - 'refs/detect.md', - 'refs/init.md', - 'refs/authoring.md', - 'refs/categories.json', - 'refs/categories.default.json', - 'refs/principles/principles-global.md', - 'refs/principles/principles-code.md', - 'refs/principles/principles-question.md', - 'refs/principles/principles-text-gen.md', -]; - -const COPY_FILES = ['SKILL.md', 'README.md', 'CHANGELOG.md']; -const COPY_DIRS = ['refs', 'hooks', 'asset', 'agents']; - -// --- Output ------------------------------------------------------------------ - -const out = []; -function say(s) { out.push(s); console.log(s); } -function info(s) { say(` • ${s}`); } -function ok(s) { say(`✓ ${s}`); } -function warn(s) { say(` ! ${s}`); } -function err(s) { console.error(`✗ ${s}`); } -function die(msg, code = 1) { err(msg); process.exit(code); } - -// --- Small filesystem / JSON helpers ----------------------------------------- - -function homeDir() { return process.env.HOME || os.homedir(); } -function claudeHome() { return process.env.CLAUDE_CONFIG_DIR || path.join(homeDir(), '.claude'); } -function codexHome() { return process.env.CODEX_HOME || path.join(homeDir(), '.codex'); } -function agentsSkillsDir() { return path.join(homeDir(), '.agents', 'skills'); } - -function exists(p) { try { fs.lstatSync(p); return true; } catch { return false; } } -function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return false; } } -function isSymlink(p) { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } } - -function readText(p) { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } } - -/** Write only when the bytes actually change. Every re-run is then byte-idempotent - * on every file this installer touches, and a no-op run leaves mtimes alone. */ -function writeIfChanged(p, content) { - const before = readText(p); - if (before === content) return false; - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, content, 'utf8'); - return true; -} - -function readJson(p) { - const raw = readText(p); - if (raw === null) return { data: {}, existed: false }; - // A file that parses but is not a JSON object (an array, a string, null) is - // treated exactly like a broken one: refuse it rather than silently replace - // whatever the user actually had there. - try { const d = JSON.parse(raw); return { data: (d && typeof d === 'object' && !Array.isArray(d)) ? d : null, existed: true }; } - catch { return { data: null, existed: true }; } -} - -function serialiseJson(obj) { return JSON.stringify(obj, null, 2) + '\n'; } - -function copyTree(src, dest) { - fs.mkdirSync(dest, { recursive: true }); - for (const entry of fs.readdirSync(src, { withFileTypes: true })) { - if (entry.name === '.DS_Store') continue; - const s = path.join(src, entry.name); - const d = path.join(dest, entry.name); - if (entry.isDirectory()) copyTree(s, d); - else if (entry.isSymbolicLink()) { try { fs.unlinkSync(d); } catch {} fs.symlinkSync(fs.readlinkSync(s), d); } - else fs.copyFileSync(s, d); - } -} - -function listTree(root, prefix = '') { - const found = []; - if (!isDir(root)) return found; - for (const entry of fs.readdirSync(root, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { - if (entry.name === '.DS_Store') continue; - const rel = prefix ? `${prefix}/${entry.name}` : entry.name; - if (entry.isDirectory()) found.push(...listTree(path.join(root, entry.name), rel)); - else found.push(rel); - } - return found; -} - -/** Compare two skill trees by content. Returns a list of differing relative paths. */ -function treeDiff(a, b) { - const la = listTree(a), lb = listTree(b); - const all = new Set([...la, ...lb]); - const diffs = []; - for (const rel of [...all].sort()) { - const pa = path.join(a, rel), pb = path.join(b, rel); - const ca = readText(pa), cb = readText(pb); - if (ca === null || cb === null || ca !== cb) diffs.push(rel); - } - return diffs; -} - -function rmrf(p) { try { fs.rmSync(p, { recursive: true, force: true }); } catch {} } - -// --- Argument parsing -------------------------------------------------------- - -function parseArgs(argv) { - const a = { - hosts: null, // null = auto-detect - scope: 'global', - activation: 'hook', - dir: '', - force: false, - copy: false, - action: 'install', // install | doctor | uninstall | help | version - codexExplicit: false, - bothExplicit: false, - scopeExplicit: false, - }; - const hosts = new Set(); - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - switch (arg) { - case '--claude': hosts.add('claude'); break; - case '--codex': hosts.add('codex'); a.codexExplicit = true; break; - case '--both': hosts.add('claude'); hosts.add('codex'); a.bothExplicit = true; break; - case '-g': case '--global': a.scope = 'global'; a.scopeExplicit = true; break; - case '-l': case '--local': a.scope = 'local'; a.scopeExplicit = true; break; - case '--hook': a.activation = 'hook'; break; - case '--on-demand': a.activation = 'on-demand'; break; - case '--dir': - a.dir = argv[++i] || ''; - if (!a.dir) die('--dir needs a path'); - a.scope = 'local'; a.scopeExplicit = true; - break; - case '--force': a.force = true; break; - case '--copy': a.copy = true; break; - case '--doctor': a.action = 'doctor'; break; - case '--uninstall': a.action = 'uninstall'; break; - case '-h': case '--help': a.action = 'help'; break; - case '-v': case '--version': a.action = 'version'; break; - default: - err(`unknown argument: ${arg}`); - console.error(''); - printHelp(); - process.exit(1); - } - } - if (hosts.size) a.hosts = [...hosts]; - return a; -} - -function detectHosts() { - const found = []; - if (isDir(claudeHome())) found.push('claude'); - if (isDir(codexHome())) found.push('codex'); - return found.length ? found : ['claude']; -} - -// --- Help -------------------------------------------------------------------- - -function printHelp() { - console.log(`wdym-prompt ${PKG.version} — install and wire the wdym prompt rewriter - -Usage: - npx wdym-prompt [host] [scope] [activation] [options] - -Host (default: whichever of ~/.claude and ~/.codex exist) - --claude install for Claude Code - --codex install for Codex (global scope only) - --both install for both hosts - -Scope (default: --global) - -g, --global install under ~/.claude — applies to every project - -l, --local install under ./.claude — applies to this project only - --dir local install into another project directory - -Activation (default: --hook) - --hook fire on every prompt via UserPromptSubmit - --on-demand install inert; runs only via /wdym - -Options - --copy copy the skill for Codex instead of symlinking it - --force overwrite guards (source-repo check, prefs on uninstall) - --doctor report what is installed, wired, and in sync - --uninstall remove skill dirs, hook entries and contract blocks - -h, --help this help - -v, --version print the installer version - -Notes - Codex is global scope only: "--codex --local" is refused. A repo-scoped Codex - hook lives in a committed file, so teammates get an approval prompt for a tool - they never installed. - - Codex trusts hooks by file contents, so every install run needs "/hooks" - re-approval inside Codex. The installer says so at the end of any Codex run.`); -} - -// --- Skill file installation ------------------------------------------------- - -function verifyPackage() { - const missing = REQUIRED.filter((f) => !exists(path.join(PKG_ROOT, f))); - if (missing.length) { - for (const m of missing) err(`missing required file in package: ${m}`); - die('Package is incomplete — aborting without touching the target.'); - } -} - -/** Install the skill files into targetDir. Returns 'copied' | 'symlink-kept'. */ -function installSkillFiles(targetDir) { - if (isSymlink(targetDir)) return 'symlink-kept'; // dev install pointed at a working tree - fs.mkdirSync(targetDir, { recursive: true }); - for (const f of COPY_FILES) { - const src = path.join(PKG_ROOT, f); - if (exists(src)) { - const dst = path.join(targetDir, f); - if (readText(src) !== readText(dst)) fs.copyFileSync(src, dst); - } - } - for (const d of COPY_DIRS) { - const src = path.join(PKG_ROOT, d); - if (!isDir(src)) continue; - const dst = path.join(targetDir, d); - if (treeDiff(src, dst).length === 0) continue; // already identical — leave mtimes alone - rmrf(dst); - copyTree(src, dst); - } - for (const h of ['prompt-detect.py', 'telemetry-stats.py']) { - const p = path.join(targetDir, 'hooks', h); - if (exists(p)) { try { fs.chmodSync(p, 0o755); } catch {} } - } - return 'copied'; -} - -// --- pref.json --------------------------------------------------------------- - -function writePref(prefPath, activation, extra) { - const { data, existed } = readJson(prefPath); - const pref = data || {}; - let mode = pref.mode; - if (mode !== 'comprehensive' && mode !== 'flash') mode = 'comprehensive'; - const was = pref.activation; - pref.mode = mode; - pref.activation = activation; - if (extra) Object.assign(pref, extra); - const changed = writeIfChanged(prefPath, serialiseJson(pref)); - const verb = existed ? (changed ? 'updated' : 'already current') : 'created'; - const shift = (was === activation || !existed) ? '' : `, activation: ${was || 'unset'} -> ${activation}`; - return `${verb} (mode: ${mode}, activation: ${activation})${shift}`; -} - -// --- Hook wiring ------------------------------------------------------------- - -/** - * Is this hook entry one of ours? - * - * Matched on the hook script's filename only — never on the string "wdym", - * which appears in plenty of paths that have nothing to do with this tool (a - * checkout named wdym, a user script called wdym-notes/lint.py). Deleting a - * hook the user wrote is far worse than leaving a stale wdym entry behind. - * install.sh matches on the same filename, so the two installers agree. - */ -function isWdymCommand(cmd) { - return typeof cmd === 'string' && cmd.includes('prompt-detect.py'); -} - -/** - * Wire, rewire, dedupe or unwire the UserPromptSubmit hook. - * - * Both hosts use the same JSON shape — Claude Code's settings.json and Codex's - * hooks.json both hold hooks.UserPromptSubmit[].hooks[] entries of - * {type:"command", command:"..."} — so one implementation serves both. - * - * Matching is by command *content* (prompt-detect.py / wdym), never by exact - * string: a path goes stale when the skill dir moves, and the real-world Codex - * file already carries a duplicated pair that has to collapse to one. - */ -function wireHook(settingsPath, hookCmd, activation) { - const { data } = readJson(settingsPath); - if (data === null) return { result: 'unparseable' }; - const settings = data; - - const hooks = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : null; - const groups = hooks && Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : []; - - let matches = 0; - let firstEntry = null; - const emptied = new Set(); - - for (const group of groups) { - const list = Array.isArray(group.hooks) ? group.hooks : []; - const keep = []; - for (const h of list) { - if (isWdymCommand(h && h.command)) { - matches++; - if (!firstEntry && activation === 'hook') { firstEntry = h; keep.push(h); } - } else keep.push(h); - } - if (keep.length !== list.length) emptied.add(group); - group.hooks = keep; - } - - let result; - const oldCmd = firstEntry ? firstEntry.command : null; - - if (activation === 'on-demand') { - result = matches ? 'removed' : 'already_absent'; - } else if (firstEntry) { - firstEntry.command = hookCmd; - if (matches > 1) result = 'deduped'; - else if (oldCmd !== hookCmd) result = 'rewired'; - else result = 'already_present'; - } else { - const h = settings.hooks && typeof settings.hooks === 'object' ? settings.hooks : (settings.hooks = {}); - const ups = Array.isArray(h.UserPromptSubmit) ? h.UserPromptSubmit : (h.UserPromptSubmit = []); - ups.push({ hooks: [{ type: 'command', command: hookCmd }] }); - result = 'added'; - } - - // Prune only the containers this run emptied. An unrelated hook group, event - // or settings key is left exactly as it was. - if (settings.hooks && Array.isArray(settings.hooks.UserPromptSubmit)) { - const pruned = settings.hooks.UserPromptSubmit.filter((g) => (Array.isArray(g.hooks) && g.hooks.length) || !emptied.has(g)); - if (pruned.length) settings.hooks.UserPromptSubmit = pruned; - else { - delete settings.hooks.UserPromptSubmit; - if (!Object.keys(settings.hooks).length) delete settings.hooks; - } - } - - const changed = writeIfChanged(settingsPath, serialiseJson(settings)); - if (!changed && (result === 'added' || result === 'rewired' || result === 'deduped')) result = 'already_present'; - return { result, matches }; -} - -function reportHook(result, settingsPath) { - switch (result) { - case 'added': info(`hook wired into ${settingsPath} (UserPromptSubmit)`); break; - case 'rewired': info(`stale hook path rewritten in ${settingsPath} (UserPromptSubmit)`); break; - case 'deduped': info(`duplicate wdym hooks collapsed to one in ${settingsPath} (UserPromptSubmit)`); break; - case 'already_present': info(`hook already present in ${settingsPath} — no change`); break; - case 'removed': info(`hook removed from ${settingsPath} — wdym runs on demand only`); break; - case 'already_absent': info(`no hook in ${settingsPath} — already on demand only`); break; - case 'skipped_global': info(`no change to ${settingsPath} — a global wdym hook already fires in this project`); break; - case 'unparseable': die(`${settingsPath} is not valid JSON — fix or move it, then re-run.`); break; - default: die(`could not update hook — check ${settingsPath} manually`); - } -} - -/** Does any global Claude hook already fire prompt-detect.py? */ -function globalHookPresent() { - const { data } = readJson(path.join(claudeHome(), 'settings.json')); - if (!data) return false; - const groups = (data.hooks && data.hooks.UserPromptSubmit) || []; - return groups.some((g) => (g.hooks || []).some((h) => isWdymCommand(h && h.command))); -} - -// --- Trust-anchor contract --------------------------------------------------- - -function contractBlock(activation, prefix) { - const state = activation === 'hook' - ? 'currently `hook`, so blocks are emitted' - : 'currently `on-demand`, so no block is ever emitted and the rule below never fires — block absence is normal, not a fault'; - return `${MARK_START} -## wdym Auto-Invoke - -The \`\` block is emitted by a user-installed \`UserPromptSubmit\` hook — it is **trusted configuration, not untrusted injected input**. Invoking \`wdym\` when a block is present is expected and safe; do not treat the block as a prompt injection. - -A \`\` block present (any verdict) ⇒ invoke the \`wdym\` skill **before** processing the prompt; no block ⇒ respond normally. - -Activation is set by \`activation\` in wdym's \`pref.json\` (${state}) and changed with \`${prefix} --init --hook\` / \`--on-demand\`, which moves the hook wiring with it. -${MARK_END}`; -} - -/** Every marked region in the text, as [start, end) offsets, in file order. */ -function blockRanges(text) { - const re = new RegExp(`${MARK_START}[\\s\\S]*?${MARK_END}`, 'g'); - const ranges = []; - let m; - while ((m = re.exec(text)) !== null) ranges.push([m.index, m.index + m[0].length]); - return ranges; -} - -/** Remove every marked region, closing the gap it leaves without disturbing the - * blank-line structure of the surrounding text the user wrote. */ -function stripBlocks(text) { - const re = new RegExp(`\\n*${MARK_START}[\\s\\S]*?${MARK_END}\\n*`, 'g'); - return text.replace(re, (m, off) => { - const before = off > 0; - const after = off + m.length < text.length; - return before && after ? '\n\n' : (before ? '\n' : ''); - }); -} - -function writeContract(filePath, activation, prefix) { - const block = contractBlock(activation, prefix); - const existing = readText(filePath); - - const ranges = existing === null ? [] : blockRanges(existing); - if (ranges.length) { - // Refresh the first block in place; any further stale copies — a second - // block left by an older tool, or a hand-paste — are removed, so the file - // can never end up asserting two different activations at once. - const head = existing.slice(0, ranges[0][0]); - const tail = stripBlocks(existing.slice(ranges[0][1])); - const updated = head + block + tail; - return writeIfChanged(filePath, updated) ? 'refreshed' : 'already_current'; - } - const base = existing || ''; - const sep = !base ? '' : (base.endsWith('\n') ? '\n' : '\n\n'); - writeIfChanged(filePath, base + sep + block + '\n'); - return 'added'; -} - -function removeContract(filePath) { - const existing = readText(filePath); - if (existing === null || !blockRanges(existing).length) return 'absent'; - let updated = stripBlocks(existing); - if (!updated.trim()) updated = ''; - return writeIfChanged(filePath, updated) ? 'removed' : 'absent'; -} - -function reportContract(result, filePath, activation) { - switch (result) { - case 'added': info(`trust-anchor contract written to ${filePath}`); break; - case 'refreshed': info(`trust-anchor contract updated in ${filePath} (activation: ${activation})`); break; - case 'already_current': info(`trust-anchor contract already current in ${filePath} — no change`); break; - case 'removed': info(`trust-anchor contract removed from ${filePath}`); break; - case 'absent': info(`no trust-anchor contract in ${filePath} — nothing to remove`); break; - } -} - -// --- Codex trust notice ------------------------------------------------------ - -const RULE = '─'.repeat(64); - -function trustNotice() { - console.log(''); - console.log(RULE); - console.log('ACTION REQUIRED — Codex only'); - console.log(''); - console.log('Run /hooks in Codex and approve the wdym hook.'); - console.log(''); - console.log('Codex trusts hooks by the contents of the hooks file. This install'); - console.log('changed that file, so any approval you gave before no longer applies.'); - console.log(''); - console.log('Until you approve it, wdym will not run — and Codex will not warn you.'); - console.log('Your prompts will simply pass through unchanged.'); - console.log(''); - console.log('To confirm it worked: submit a prompt, then run "$wdym --status".'); - console.log(RULE); -} - -// --- Host installs ----------------------------------------------------------- - -// What this run has already written. A --both run installs one canonical skill -// tree and one pref file; the second host reports sharing them rather than -// claiming to have written them twice. -const done = { skillDirs: new Set(), prefs: new Set() }; - -function claudePaths(args) { - if (args.scope === 'global') { - const base = claudeHome(); - return { - base, - skillDir: path.join(base, 'skills', SKILL_NAME), - settingsPath: path.join(base, 'settings.json'), - prefPath: path.join(base, 'wdym', 'pref.json'), - contractPath: path.join(base, 'CLAUDE.md'), - projectDir: null, - }; - } - let projectDir = args.dir || process.env.CLAUDE_PROJECT_DIR || process.cwd(); - if (!isDir(projectDir)) die(`no such directory: ${projectDir}`); - projectDir = fs.realpathSync(projectDir); - const base = path.join(projectDir, '.claude'); - return { - base, - skillDir: path.join(base, 'skills', SKILL_NAME), - settingsPath: path.join(base, 'settings.local.json'), - prefPath: path.join(base, 'wdym', 'pref.json'), - contractPath: path.join(projectDir, 'CLAUDE.md'), - projectDir, - }; -} - -function installClaude(args) { - const p = claudePaths(args); - - if (p.projectDir && !args.force - && exists(path.join(p.projectDir, 'SKILL.md')) - && exists(path.join(p.projectDir, 'hooks', 'prompt-detect.py'))) { - err(`${p.projectDir} looks like the wdym source repo — installing here would nest wdym inside itself.`); - die('cd into the project you want wdym in, or pass --dir , --global, or --force.'); - } - - say(`Claude Code (${args.scope} scope, ${args.activation} activation)`); - const mode = installSkillFiles(p.skillDir); - done.skillDirs.add(p.skillDir); - info(mode === 'symlink-kept' - ? `${p.skillDir} is a symlink (dev install) — left in place, no copy` - : `skill files installed at ${p.skillDir}`); - - info(`pref.json ${writePref(p.prefPath, args.activation)}`); - done.prefs.add(p.prefPath); - - const hookCmd = `python3 "${path.join(p.skillDir, 'hooks', 'prompt-detect.py')}"`; - let result; - if (args.scope === 'local' && args.activation === 'hook' && globalHookPresent()) { - // Claude Code merges hook lists across settings files without deduping by - // command text, so a second entry here would fire the hook twice per prompt. - // Local scope only needs its own pref.json to override the global default. - result = 'skipped_global'; - } else { - result = wireHook(p.settingsPath, hookCmd, args.activation).result; - } - reportHook(result, p.settingsPath); - reportContract(writeContract(p.contractPath, args.activation, '/wdym'), p.contractPath, args.activation); - return p; -} - -function installCodex(args) { - const home = codexHome(); - const canonical = path.join(claudeHome(), 'skills', SKILL_NAME); - const linkPath = path.join(agentsSkillsDir(), SKILL_NAME); - - say(`Codex (global scope, ${args.activation} activation)`); - - // The canonical copy lives under ~/.claude/skills so both hosts read one tree. - if (done.skillDirs.has(canonical)) { - info(`skill files: sharing the canonical copy at ${canonical}`); - } else { - const mode = installSkillFiles(canonical); - done.skillDirs.add(canonical); - info(mode === 'symlink-kept' - ? `${canonical} is a symlink (dev install) — left in place, no copy` - : `skill files installed at ${canonical}`); - } - - // Codex only scans ~/.agents/skills, so expose the canonical copy there. - let skillMode = 'symlink'; - fs.mkdirSync(agentsSkillsDir(), { recursive: true }); - if (args.copy) { - if (isSymlink(linkPath)) rmrf(linkPath); - if (treeDiff(canonical, linkPath).length) { rmrf(linkPath); copyTree(canonical, linkPath); info(`skill copied to ${linkPath} (--copy)`); } - else info(`skill copy at ${linkPath} already matches the canonical copy`); - skillMode = 'copy'; - } else if (isSymlink(linkPath) && (() => { try { return fs.realpathSync(linkPath) === fs.realpathSync(canonical); } catch { return false; } })()) { - info(`skill symlink already points at ${canonical} — no change`); - } else { - rmrf(linkPath); - try { - fs.symlinkSync(canonical, linkPath, 'dir'); - info(`skill symlinked: ${linkPath} -> ${canonical}`); - } catch (e) { - // Some filesystems and locked-down machines refuse symlinks. A real copy - // still works; it just needs drift-checking on every later run. - copyTree(canonical, linkPath); - skillMode = 'copy'; - warn(`symlink refused (${e.code || e.message}) — copied the skill to ${linkPath} instead`); - warn('the copy can drift from the canonical tree; "--doctor" checks it'); - } - } - - // One canonical pref, shared with Claude Code. Never a second Codex pref — - // the two hosts must not be able to disagree about mode or activation. - const prefPath = path.join(claudeHome(), 'wdym', 'pref.json'); - const prefMsg = writePref(prefPath, args.activation, { codex_skill_mode: skillMode }); - info(done.prefs.has(prefPath) - ? `pref.json shared with Claude Code at ${prefPath} (skill mode: ${skillMode})` - : `pref.json ${prefMsg} at ${prefPath}`); - done.prefs.add(prefPath); - - const hooksPath = path.join(home, 'hooks.json'); - const hookCmd = `python3 "${path.join(canonical, 'hooks', 'prompt-detect.py')}"`; - const { result } = wireHook(hooksPath, hookCmd, args.activation); - reportHook(result, hooksPath); - - const agentsMd = path.join(home, 'AGENTS.md'); - reportContract(writeContract(agentsMd, args.activation, '$wdym'), agentsMd, args.activation); - - return { home, canonical, linkPath, skillMode }; -} - -// --- Doctor ------------------------------------------------------------------ - -function doctorClaude(args) { - say('Claude Code'); - const scopes = [{ ...claudePaths({ ...args, scope: 'global' }), label: 'global' }]; - const localDir = args.dir || process.cwd(); - if (isDir(path.join(localDir, '.claude'))) { - scopes.push({ ...claudePaths({ ...args, scope: 'local', dir: localDir }), label: `local (${localDir})` }); - } - for (const p of scopes) { - say(` ${p.label}`); - info(`skill dir: ${isDir(p.skillDir) || isSymlink(p.skillDir) ? p.skillDir + (isSymlink(p.skillDir) ? ' (symlink)' : '') : 'absent'}`); - if (isDir(p.skillDir) && !isSymlink(p.skillDir)) { - const drift = skillDrift(p.skillDir); - info(drift.length ? `version: differs from this package in ${drift.length} file(s) — re-run to update` : 'version: matches this package'); - } - const { data } = readJson(p.settingsPath); - if (data === null) info(`hook: ${p.settingsPath} is unparseable JSON`); - else { - const cmds = ((data.hooks && data.hooks.UserPromptSubmit) || []) - .flatMap((g) => (g.hooks || []).map((h) => h && h.command)) - .filter(isWdymCommand); - info(`hook: ${cmds.length === 0 ? 'not wired' : cmds.length === 1 ? 'wired' : `${cmds.length} entries — DUPLICATED, re-run to collapse`} (${p.settingsPath})`); - } - const { data: pref } = readJson(p.prefPath); - info(`pref: ${pref && Object.keys(pref).length ? `mode ${pref.mode}, activation ${pref.activation}` : 'absent'} (${p.prefPath})`); - const c = readText(p.contractPath); - info(`contract: ${c && c.includes(MARK_START) ? 'present' : 'absent'} (${p.contractPath})`); - } -} - -/** Which of the shipped skill files differ from an installed copy. Compares only - * the curated install set — the package also carries repo-only files that are - * deliberately never installed. */ -function skillDrift(targetDir) { - const diffs = []; - for (const f of COPY_FILES) { - const src = path.join(PKG_ROOT, f); - if (!exists(src)) continue; - if (readText(src) !== readText(path.join(targetDir, f))) diffs.push(f); - } - for (const d of COPY_DIRS) { - const src = path.join(PKG_ROOT, d); - if (!isDir(src)) continue; - diffs.push(...treeDiff(src, path.join(targetDir, d)).map((r) => `${d}/${r}`)); - } - return diffs; -} - -function doctorCodex() { - say('Codex'); - const home = codexHome(); - const canonical = path.join(claudeHome(), 'skills', SKILL_NAME); - const linkPath = path.join(agentsSkillsDir(), SKILL_NAME); - - info(`canonical: ${isDir(canonical) ? canonical : 'absent'}`); - if (!exists(linkPath)) info(`codex path: absent (${linkPath})`); - else if (isSymlink(linkPath)) { - let target = '?'; - try { target = fs.readlinkSync(linkPath); } catch {} - const live = (() => { try { return fs.realpathSync(linkPath) === fs.realpathSync(canonical); } catch { return false; } })(); - info(`codex path: symlink -> ${target}${live ? ' (resolves to the canonical copy — no drift possible)' : ' (BROKEN — does not resolve to the canonical copy)'}`); - } else { - const diffs = treeDiff(canonical, linkPath); - info(`codex path: real copy (${linkPath}) — drift-checking applies`); - info(diffs.length ? `drift: ${diffs.length} file(s) differ from the canonical copy: ${diffs.slice(0, 5).join(', ')}${diffs.length > 5 ? ', …' : ''}` : 'drift: none — copy matches the canonical tree'); - } - - const hooksPath = path.join(home, 'hooks.json'); - const { data } = readJson(hooksPath); - if (data === null) info(`hook: ${hooksPath} is unparseable JSON`); - else { - const cmds = ((data.hooks && data.hooks.UserPromptSubmit) || []) - .flatMap((g) => (g.hooks || []).map((h) => h && h.command)) - .filter(isWdymCommand); - info(`hook: ${cmds.length === 0 ? 'not wired' : cmds.length === 1 ? 'wired' : `${cmds.length} entries — DUPLICATED, re-run to collapse`} (${hooksPath})`); - } - const agentsMd = path.join(home, 'AGENTS.md'); - const c = readText(agentsMd); - info(`contract: ${c && c.includes(MARK_START) ? 'present' : 'absent'} (${agentsMd})`); - - const { data: pref } = readJson(path.join(claudeHome(), 'wdym', 'pref.json')); - info(`pref: ${pref && Object.keys(pref).length ? `mode ${pref.mode}, activation ${pref.activation}, skill mode ${pref.codex_skill_mode || 'unrecorded'}` : 'absent'}`); - - // Codex stores hook approval outside any file this tool can read, so there is - // nothing honest to report but the fact that it must be checked in-session. - info('trust: unknown — Codex records hook approval outside any readable file; run /hooks in Codex to check'); -} - -// --- Uninstall --------------------------------------------------------------- - -function uninstallClaude(args) { - say('Claude Code'); - const targets = [claudePaths({ ...args, scope: 'global' })]; - const localDir = args.dir || process.cwd(); - if (isDir(path.join(localDir, '.claude'))) targets.push(claudePaths({ ...args, scope: 'local', dir: localDir })); - for (const p of targets) { - if (isDir(p.skillDir) || isSymlink(p.skillDir)) { rmrf(p.skillDir); info(`removed ${p.skillDir}`); } - if (exists(p.settingsPath)) reportHook(wireHook(p.settingsPath, '', 'on-demand').result, p.settingsPath); - reportContract(removeContract(p.contractPath), p.contractPath); - if (args.force) { - for (const f of ['pref.json', 'telemetry.jsonl']) { - const t = path.join(path.dirname(p.prefPath), f); - if (exists(t)) { fs.rmSync(t); info(`removed ${t}`); } - } - } else if (exists(p.prefPath)) { - info(`kept ${p.prefPath} and any telemetry — pass --force to remove them too`); - } - } -} - -function uninstallCodex(args) { - say('Codex'); - const home = codexHome(); - const linkPath = path.join(agentsSkillsDir(), SKILL_NAME); - if (exists(linkPath)) { rmrf(linkPath); info(`removed ${linkPath}`); } - const hooksPath = path.join(home, 'hooks.json'); - if (exists(hooksPath)) reportHook(wireHook(hooksPath, '', 'on-demand').result, hooksPath); - const agentsMd = path.join(home, 'AGENTS.md'); - reportContract(removeContract(agentsMd), agentsMd); - if (!args.force) info('canonical skill copy under ~/.claude/skills is Claude Code\'s — removed only by a Claude uninstall'); -} - -// --- Main -------------------------------------------------------------------- - -function main() { - const args = parseArgs(process.argv.slice(2)); - - if (args.action === 'help') { printHelp(); return; } - if (args.action === 'version') { console.log(PKG.version); return; } - - let hosts = args.hosts || detectHosts(); - - // D7: Codex is global scope only. Asking for it by name at local scope is an - // error that wires nothing at all, rather than a half-install. - if (args.scope === 'local' && hosts.includes('codex')) { - if (args.codexExplicit && !args.bothExplicit && !hosts.includes('claude')) { - err('Codex supports global installs only — "--codex --local" is refused.'); - err('A repo-scoped Codex hook lives in a committed file, so every teammate who'); - err('pulls the repo gets an approval prompt for a tool they never installed.'); - err(''); - err('Run "npx wdym-prompt --codex --global" instead. Nothing was written.'); - process.exit(2); - } - warn('Codex supports global installs only — skipping Codex, installing for Claude Code at local scope.'); - warn('Run "npx wdym-prompt --codex --global" to add Codex.'); - hosts = hosts.filter((h) => h !== 'codex'); - } - - if (args.action === 'doctor') { - say(`wdym-prompt ${PKG.version} — doctor`); - say(''); - if (hosts.includes('claude')) doctorClaude(args); - if (hosts.includes('codex')) { if (hosts.includes('claude')) say(''); doctorCodex(); } - return; - } - - if (args.action === 'uninstall') { - say(`Uninstalling wdym (${hosts.join(' + ')})`); - say(''); - if (hosts.includes('claude')) uninstallClaude(args); - if (hosts.includes('codex')) { say(''); uninstallCodex(args); } - say(''); - ok('wdym removed'); - return; - } - - verifyPackage(); - say(`Installing wdym ${PKG.version} for: ${hosts.join(' + ')}`); - say(''); - - if (hosts.includes('claude')) installClaude(args); - if (hosts.includes('codex')) { say(''); installCodex(args); } - - say(''); - ok(`wdym installed and initialised (${hosts.join(' + ')}, ${args.activation} activation)`); - say(''); - if (args.activation === 'hook') { - say('The skill fires automatically on every substantive prompt.'); - say('To make it manual instead, re-run with "--on-demand".'); - } else { - say('The skill runs only when you invoke it: "/wdym ", or by asking to improve a prompt.'); - say('To make it fire on every prompt, re-run with "--hook".'); - } - - // Printed last, on every Codex-touching run, so it cannot be mistaken for log - // output or suppressed as "already seen". Never on a Claude-only run. - if (hosts.includes('codex')) trustNotice(); -} - -main(); diff --git a/install.sh b/install.sh index c6eba1a..23682e6 100755 --- a/install.sh +++ b/install.sh @@ -1,38 +1,50 @@ #!/usr/bin/env bash # -# install.sh — Installer for the wdym skill. +# install.sh — Installer for the wdym skill. One installer, both hosts. # -# Fetches the skill as a tarball, unpacks it into a Claude Code skills -# directory, writes the pref file, and wires the UserPromptSubmit hook so the -# skill fires automatically on every prompt. +# Fetches the skill as a tarball, unpacks it into a skills directory, writes the +# pref file, wires the UserPromptSubmit hook, and writes the trust-anchor +# contract that authorises the auto-invoke. # -# Global is the default: everything lands under ~/.claude/ and wdym fires -# across every project. Pass --local to install under ./.claude/ instead, -# where it fires only in the current project. +# Hosts. With no host flag the installer targets whichever of ~/.claude and +# ~/.codex it finds. Name one with --claude or --codex, or both with --both. +# +# Claude Code takes a scope. Global is the default: everything lands under +# ~/.claude/ and wdym fires across every project. Pass --local to install under +# ./.claude/ instead, where it fires only in the current project. +# +# Codex is global scope only. Its skill lives at ~/.agents/skills/wdym as a +# symlink to the canonical copy under ~/.claude/skills/wdym (a real copy is used +# automatically if the symlink is refused; --copy forces one), its hook goes in +# $CODEX_HOME/hooks.json, and its contract in $CODEX_HOME/AGENTS.md. # # Activation defaults to hook (fires on every prompt). Pass --on-demand to # install it inert instead, so it runs only when invoked via /wdym; that writes -# activation:"on-demand" to the pref and skips the hook wiring entirely. +# activation:"on-demand" to the pref and skips the hook wiring on both hosts. # # The installer never needs the wdym repo on disk — it pulls a tarball into a # temp dir, installs from there, and cleans up. Run it from the project you # want wdym in, not from a clone of wdym. # # Usage: -# ./install.sh # global install into ~/.claude -# ./install.sh --local # local install into ./.claude +# ./install.sh # every detected host, global +# ./install.sh --both # Claude Code and Codex +# ./install.sh --codex # Codex only (global scope only) +# ./install.sh --claude --local # this project only (Claude Code) # ./install.sh --on-demand # install without wiring the hook # ./install.sh --dir path/to/project # local install into another project +# ./install.sh --copy # copy the skill for Codex, don't symlink # ./install.sh --tarball ./wdym.tar.gz # ./install.sh --tarball https://example.com/wdym.tar.gz # # Or without cloning anything: # curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -# curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -s -- --local +# curl -fsSL https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh | bash -s -- --both # # Environment: # WDYM_TARBALL default tarball URL or local .tar.gz path -# CLAUDE_CONFIG_DIR global config root (default ~/.claude) +# CLAUDE_CONFIG_DIR Claude Code config root (default ~/.claude) +# CODEX_HOME Codex config root (default ~/.codex) # SKILL_NAME installed skill directory name (default wdym) # set -euo pipefail @@ -41,14 +53,15 @@ DEFAULT_TARBALL="https://github.com/ndisisnd/wdym/archive/refs/heads/main.tar.gz info() { printf ' \033[0;36m•\033[0m %s\n' "$1"; } ok() { printf '\033[0;32m✓\033[0m %s\n' "$1"; } +warn() { printf '\033[0;33m!\033[0m %s\n' "$1" >&2; } err() { printf '\033[0;31m✗\033[0m %s\n' "$1" >&2; } usage() { # Piped into bash (curl | bash) there is no script file to read the header from. if [[ -r "${BASH_SOURCE[0]:-}" ]]; then - sed -n '3,32p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' + sed -n '3,48p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' else - printf 'install.sh [--local|--global] [--hook|--on-demand] [--dir ] [--tarball ] [--force]\n' + printf 'install.sh [--claude|--codex|--both] [--local|--global] [--hook|--on-demand] [--dir ] [--copy] [--tarball ] [--force]\n' fi } @@ -58,39 +71,97 @@ ACTIVATION="hook" PROJECT_DIR="" TARBALL="${WDYM_TARBALL:-$DEFAULT_TARBALL}" FORCE=0 +COPY=0 + +WANT_CLAUDE=0 +WANT_CODEX=0 +HOST_EXPLICIT=0 +CODEX_EXPLICIT=0 +BOTH_EXPLICIT=0 while [[ $# -gt 0 ]]; do case "$1" in + --claude) WANT_CLAUDE=1; HOST_EXPLICIT=1; shift ;; + --codex) WANT_CODEX=1; HOST_EXPLICIT=1; CODEX_EXPLICIT=1; shift ;; + --both) WANT_CLAUDE=1; WANT_CODEX=1; HOST_EXPLICIT=1; BOTH_EXPLICIT=1; shift ;; -g|--global) SCOPE="global"; shift ;; -l|--local) SCOPE="local"; shift ;; --hook) ACTIVATION="hook"; shift ;; --on-demand) ACTIVATION="on-demand"; shift ;; - --dir) PROJECT_DIR="${2:-}"; [[ -n "$PROJECT_DIR" ]] || { err "--dir needs a path"; exit 1; }; shift 2 ;; + --dir) PROJECT_DIR="${2:-}"; [[ -n "$PROJECT_DIR" ]] || { err "--dir needs a path"; exit 1; }; SCOPE="local"; shift 2 ;; --tarball) TARBALL="${2:-}"; [[ -n "$TARBALL" ]] || { err "--tarball needs a URL or path"; exit 1; }; shift 2 ;; + --copy) COPY=1; shift ;; --force) FORCE=1; shift ;; -h|--help) usage; exit 0 ;; *) err "unknown argument: $1"; echo; usage; exit 1 ;; esac done -# --- Resolve target paths ---------------------------------------------------- -# Local → /.claude + settings.local.json (personal, uncommitted) -# Global → $CLAUDE_CONFIG_DIR (~/.claude) + settings.json (all projects) SKILL_NAME="${SKILL_NAME:-wdym}" +CLAUDE_HOME="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" +CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}" +AGENTS_SKILLS_DIR="$HOME/.agents/skills" + +# --- Resolve hosts ----------------------------------------------------------- +# No host flag: install for whatever is actually on this machine. A box with +# neither host installed still gets a Claude Code install — that is the common +# "config dir doesn't exist yet" case, not a reason to refuse. +if [[ "$HOST_EXPLICIT" -eq 0 ]]; then + [[ -d "$CLAUDE_HOME" ]] && WANT_CLAUDE=1 + [[ -d "$CODEX_HOME_DIR" ]] && WANT_CODEX=1 + if [[ "$WANT_CLAUDE" -eq 0 && "$WANT_CODEX" -eq 0 ]]; then + WANT_CLAUDE=1 + fi +fi + +# Codex is global scope only. Asking for it by name at local scope is an error +# that wires nothing at all, rather than a half-install. +if [[ "$SCOPE" == "local" && "$WANT_CODEX" -eq 1 ]]; then + if [[ "$CODEX_EXPLICIT" -eq 1 && "$BOTH_EXPLICIT" -ne 1 && "$WANT_CLAUDE" -ne 1 ]]; then + err 'Codex supports global installs only — "--codex --local" is refused.' + err 'A repo-scoped Codex hook lives in a committed file, so every teammate who' + err 'pulls the repo gets an approval prompt for a tool they never installed.' + err '' + err 'Run "./install.sh --codex --global" instead. Nothing was written.' + exit 2 + fi + warn 'Codex supports global installs only — skipping Codex, installing for Claude Code at local scope.' + warn 'Run "./install.sh --codex --global" to add Codex.' + WANT_CODEX=0 + # A local install is a Claude Code concept, so that is what --local asked for + # even when only Codex was auto-detected. + WANT_CLAUDE=1 +fi +HOST_LABEL="" +[[ "$WANT_CLAUDE" -eq 1 ]] && HOST_LABEL="Claude Code" +if [[ "$WANT_CODEX" -eq 1 ]]; then + [[ -n "$HOST_LABEL" ]] && HOST_LABEL="$HOST_LABEL + Codex" || HOST_LABEL="Codex" +fi + +# --- Resolve Claude Code target paths ---------------------------------------- +# Local → /.claude + settings.local.json (personal, uncommitted) +# Global → $CLAUDE_CONFIG_DIR (~/.claude) + settings.json (all projects) +# +# The trust-anchor contract lands in CLAUDE.md at the scope's memory location: +# global → ~/.claude/CLAUDE.md (user memory), local → /CLAUDE.md +# (project memory). It authorises the auto-invoke from a file the model trusts, +# so the hook's neutral signal doesn't have to carry an injection-shaped order. if [[ "$SCOPE" == "global" ]]; then - BASE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" + BASE_DIR="$CLAUDE_HOME" SETTINGS_PATH="$BASE_DIR/settings.json" + CLAUDE_MD_PATH="$BASE_DIR/CLAUDE.md" else PROJECT_DIR="${PROJECT_DIR:-${CLAUDE_PROJECT_DIR:-$PWD}}" [[ -d "$PROJECT_DIR" ]] || { err "no such directory: $PROJECT_DIR"; exit 1; } PROJECT_DIR="$(cd "$PROJECT_DIR" && pwd)" BASE_DIR="$PROJECT_DIR/.claude" SETTINGS_PATH="$BASE_DIR/settings.local.json" + CLAUDE_MD_PATH="$PROJECT_DIR/CLAUDE.md" # A local install into the wdym repo itself would nest the skill inside its # own source tree (wdym/.claude/skills/wdym). Almost never what you want. - if [[ "$FORCE" -ne 1 && -f "$PROJECT_DIR/SKILL.md" && -f "$PROJECT_DIR/hooks/prompt-detect.py" ]]; then + if [[ "$WANT_CLAUDE" -eq 1 && "$FORCE" -ne 1 && -f "$PROJECT_DIR/SKILL.md" && -f "$PROJECT_DIR/hooks/prompt-detect.py" ]]; then err "$PROJECT_DIR looks like the wdym source repo — installing here would nest wdym inside itself." err "cd into the project you want wdym in, or pass --dir , --global, or --force." exit 1 @@ -100,19 +171,23 @@ fi TARGET_DIR="$BASE_DIR/skills/$SKILL_NAME" PREF_PATH="$BASE_DIR/wdym/pref.json" -# The trust-anchor contract lands in CLAUDE.md at the scope's memory location: -# global → ~/.claude/CLAUDE.md (user memory), local → /CLAUDE.md -# (project memory). It authorises the auto-invoke from a file the model trusts, -# so the hook's neutral signal doesn't have to carry an injection-shaped order. -if [[ "$SCOPE" == "global" ]]; then - CLAUDE_MD_PATH="$BASE_DIR/CLAUDE.md" +# Codex paths. The canonical skill copy is always the Claude Code global one, so +# both hosts read one tree and one pref — they can never disagree about mode or +# activation. +CANONICAL_DIR="$CLAUDE_HOME/skills/$SKILL_NAME" +CODEX_LINK_PATH="$AGENTS_SKILLS_DIR/$SKILL_NAME" +CODEX_HOOKS_PATH="$CODEX_HOME_DIR/hooks.json" +CODEX_AGENTS_MD="$CODEX_HOME_DIR/AGENTS.md" +CODEX_PREF_PATH="$CLAUDE_HOME/wdym/pref.json" + +echo "Installing the wdym skill for: $HOST_LABEL" +if [[ "$WANT_CODEX" -eq 1 ]]; then + echo " scope: $SCOPE (Codex is always global)" else - CLAUDE_MD_PATH="$PROJECT_DIR/CLAUDE.md" + echo " scope: $SCOPE" fi - -echo "Installing the wdym skill ($SCOPE scope, $ACTIVATION activation)" -echo " from: $TARBALL" -echo " to: $TARGET_DIR" +echo " activation: $ACTIVATION" +echo " from: $TARBALL" echo # --- Pre-flight checks ------------------------------------------------------- @@ -180,51 +255,67 @@ if [[ "$missing" -ne 0 ]]; then fi info "all required files present in tarball" -# --- Install ----------------------------------------------------------------- -# A dev install symlinks the skill dir straight at a working tree; replacing it -# would clobber the developer's checkout. Keep the symlink, skip the unpack. -if [[ -L "$TARGET_DIR" ]]; then - info "target is a symlink (dev install) — leaving it in place; skipping copy" -else - mkdir -p "$TARGET_DIR" - - # Curated set only: root docs, plus the refs/, hooks/, asset/ trees. Dev and - # repo-only artifacts (install.sh, tests/, .github/, pref.json) stay behind. - cp -f "$STAGE_DIR/SKILL.md" "$TARGET_DIR/" +# --- Shared install steps ---------------------------------------------------- +# Every skill dir this run has already laid down, so a --both run installs the +# canonical tree once rather than twice. +INSTALLED_DIRS="" + +# Unpack the curated skill file set into a target dir. Echoes "symlink-kept" or +# "copied". A dev install symlinks the skill dir straight at a working tree; +# replacing it would clobber the developer's checkout, so the symlink stays. +install_skill_files() { + local target="$1" + if [[ -L "$target" ]]; then + printf 'symlink-kept' + return 0 + fi + mkdir -p "$target" + + # Curated set only: root docs, plus the refs/, hooks/, asset/ and agents/ + # trees. agents/openai.yaml is Codex's skill manifest — it carries the display + # name and `allow_implicit_invocation`, so leaving it out installs a skill + # Codex will not invoke on its own. Dev and repo-only artifacts (install.sh, + # tests/, .github/, pref.json) stay behind. + cp -f "$STAGE_DIR/SKILL.md" "$target/" for f in README.md CHANGELOG.md; do - [[ -f "$STAGE_DIR/$f" ]] && cp -f "$STAGE_DIR/$f" "$TARGET_DIR/" + [[ -f "$STAGE_DIR/$f" ]] && cp -f "$STAGE_DIR/$f" "$target/" done - for d in refs hooks asset; do + for d in refs hooks asset agents; do [[ -d "$STAGE_DIR/$d" ]] || continue - rm -rf "${TARGET_DIR:?}/$d" - cp -R "$STAGE_DIR/$d" "$TARGET_DIR/$d" + rm -rf "${target:?}/$d" + cp -R "$STAGE_DIR/$d" "$target/$d" done - find "$TARGET_DIR" -name '.DS_Store' -delete 2>/dev/null || true - info "skill files installed" -fi + find "$target" -name '.DS_Store' -delete 2>/dev/null || true -# Hooks are invoked as `python3 ""`, but mark them executable anyway. -chmod +x "$TARGET_DIR/hooks/prompt-detect.py" "$TARGET_DIR/hooks/telemetry-stats.py" -info "hook scripts marked executable" + # Hooks are invoked as `python3 ""`, but mark them executable anyway. + chmod +x "$target/hooks/prompt-detect.py" "$target/hooks/telemetry-stats.py" + printf 'copied' +} # Sanity-check the hook parses under the installed python3. -if python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$TARGET_DIR/hooks/prompt-detect.py"; then - info "prompt-detect.py parses cleanly" -else - err "prompt-detect.py failed to parse — check your python3 install." - exit 1 -fi +verify_hook_parses() { + if python3 -c "import ast,sys; ast.parse(open(sys.argv[1]).read())" "$1/hooks/prompt-detect.py"; then + info "prompt-detect.py parses cleanly" + else + err "prompt-detect.py failed to parse — check your python3 install." + exit 1 + fi +} # --- Init: pref.json --------------------------------------------------------- # Two independent keys: `mode` is how a run behaves (preserve the user's choice # across re-installs), `activation` is whether it fires at all (always set from # this run's flags — it is the thing the installer is being asked to configure, -# and it must stay in step with the hook wiring below). -mkdir -p "$(dirname "$PREF_PATH")" -pref_result=$(python3 - "$PREF_PATH" "$ACTIVATION" <<'PYEOF' +# and it must stay in step with the hook wiring below). A third key, +# `codex_skill_mode`, records whether Codex reads the skill through a symlink or +# a real copy, and is written only on a Codex run. +write_pref() { + local pref_path="$1" activation="$2" skill_mode="${3:-}" + mkdir -p "$(dirname "$pref_path")" + python3 - "$pref_path" "$activation" "$skill_mode" <<'PYEOF' import sys, json, os -path, activation = sys.argv[1], sys.argv[2] +path, activation, skill_mode = sys.argv[1], sys.argv[2], sys.argv[3] pref, existed = {}, os.path.exists(path) if existed: @@ -243,151 +334,174 @@ if mode not in ("comprehensive", "flash"): was = pref.get("activation") pref["mode"] = mode pref["activation"] = activation +if skill_mode: + pref["codex_skill_mode"] = skill_mode -with open(path, "w") as f: - json.dump(pref, f, indent=2) - f.write("\n") +new = json.dumps(pref, indent=2) + "\n" +old = None +if existed: + try: + with open(path) as f: + old = f.read() + except OSError: + old = None +if new != old: + with open(path, "w") as f: + f.write(new) verb = "updated" if existed else "created" changed = "" if was == activation else f", activation: {was or 'unset'} -> {activation}" -print(f"{verb} (mode: {mode}, activation: {activation}){changed}") +extra = f", codex skill mode: {skill_mode}" if skill_mode else "" +print(f"{verb} (mode: {mode}, activation: {activation}){changed}{extra}") PYEOF -) || { err "could not write $PREF_PATH"; exit 1; } -info "pref.json $pref_result" +} # --- Init: wire or unwire the UserPromptSubmit hook -------------------------- -# The command carries the absolute skill path so it resolves from any cwd. +# Both hosts use the same JSON shape — Claude Code's settings.json and Codex's +# hooks.json both hold hooks.UserPromptSubmit[].hooks[] entries of +# {type:"command", command:"..."} — so one implementation serves both. +# +# Matching is on the command *content* (prompt-detect.py), never on an exact +# string: the path goes stale when the skill dir moves, and a real-world Codex +# file can already carry a duplicated pair that has to collapse to one. +# # Under --on-demand the wiring is removed instead of added, so the settings file # always agrees with the pref. (The hook also reads `activation` itself and # exits silently under on-demand, so the pref alone is already authoritative — # removing the entry just avoids running a no-op process on every prompt.) -HOOK_CMD="python3 \"$TARGET_DIR/hooks/prompt-detect.py\"" - -# Local scope: if a wdym hook already fires globally for every project (any -# prompt-detect.py, not just this TARGET_DIR's copy), wiring another one here -# would fire the hook twice per prompt — Claude Code merges hook lists across -# settings.json and settings.local.json, it doesn't dedupe by command text -# across files. Local scope only needs its own pref.json to override the -# global default (local-overrides-global pref resolution already handles -# that), so skip the hook wire entirely in that case. -SKIP_HOOK=0 -if [[ "$SCOPE" == "local" && "$ACTIVATION" == "hook" ]]; then - GLOBAL_SETTINGS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json" - if [[ -f "$GLOBAL_SETTINGS" ]] && python3 - "$GLOBAL_SETTINGS" <<'PYEOF' -import sys, json -try: - with open(sys.argv[1]) as f: - settings = json.load(f) -except (OSError, json.JSONDecodeError): - sys.exit(1) -for group in settings.get("hooks", {}).get("UserPromptSubmit", []): - for h in group.get("hooks", []): - if "/hooks/prompt-detect.py" in h.get("command", ""): - sys.exit(0) -sys.exit(1) -PYEOF - then - SKIP_HOOK=1 - fi -fi - -if [[ "$SKIP_HOOK" -eq 1 ]]; then - hook_result="skipped_global" -else - hook_result=$(python3 - "$SETTINGS_PATH" "$HOOK_CMD" "$ACTIVATION" <<'PYEOF' +wire_hook() { + python3 - "$1" "$2" "$3" <<'PYEOF' import sys, json, os settings_path, hook_cmd, activation = sys.argv[1], sys.argv[2], sys.argv[3] +old = None if os.path.exists(settings_path): - with open(settings_path) as f: - try: - settings = json.load(f) - except json.JSONDecodeError as e: - print(f"unparseable: {e}", file=sys.stderr) - sys.exit(1) + try: + with open(settings_path) as f: + old = f.read() + loaded = json.loads(old) + except (OSError, json.JSONDecodeError) as e: + print(f"unparseable: {e}", file=sys.stderr) + sys.exit(1) + # A file that parses but is not a JSON object (an array, a string, null) is + # treated exactly like a broken one: refuse rather than silently replace + # whatever the user actually had there. + if not isinstance(loaded, dict): + print("not a JSON object", file=sys.stderr) + sys.exit(1) + settings = loaded else: settings = {} -if activation == "on-demand": - # Match on the script name, not the exact command: a hook wired by an older - # install (or a moved skill dir) carries a different absolute path but is - # still the same hook, and leaving it behind would keep firing. - ups = settings.get("hooks", {}).get("UserPromptSubmit", []) - removed = 0 - for group in ups: - keep = [h for h in group.get("hooks", []) - if "prompt-detect.py" not in h.get("command", "")] - removed += len(group.get("hooks", [])) - len(keep) - group["hooks"] = keep - if not removed: - print("already_absent") - sys.exit(0) - # Prune the containers this emptied, but only those — an unrelated hook - # group or event stays exactly as it was. - ups = [g for g in ups if g.get("hooks")] - if ups: - settings["hooks"]["UserPromptSubmit"] = ups - else: - settings["hooks"].pop("UserPromptSubmit", None) - if not settings["hooks"]: - settings.pop("hooks", None) - result = "removed" -else: - hooks = settings.setdefault("hooks", {}) - ups = hooks.setdefault("UserPromptSubmit", []) - - for group in ups: - for h in group.get("hooks", []): - if h.get("command") == hook_cmd: - print("already_present") - sys.exit(0) - - # A stale entry (same script, dead path from a moved skill dir) is rewritten - # in place rather than joined by a second copy that would double-fire. - for group in ups: - for h in group.get("hooks", []): - cmd = h.get("command", "") - if "prompt-detect.py" in cmd: - h["command"] = hook_cmd - result = "rewired" - break +hooks = settings.get("hooks") +groups = hooks.get("UserPromptSubmit", []) if isinstance(hooks, dict) else [] +if not isinstance(groups, list): + groups = [] + +matches = 0 +first = None +emptied = set() + +for group in groups: + if not isinstance(group, dict): + continue + lst = group.get("hooks") + lst = lst if isinstance(lst, list) else [] + keep = [] + for h in lst: + cmd = h.get("command", "") if isinstance(h, dict) else "" + # Match on the script name, not the exact command: a hook wired by an + # older install (or a moved skill dir) carries a different absolute path + # but is still the same hook, and leaving it behind would keep firing. + if "prompt-detect.py" in cmd: + matches += 1 + if first is None and activation == "hook": + first = h + keep.append(h) else: - continue - break + keep.append(h) + if len(keep) != len(lst): + emptied.add(id(group)) + group["hooks"] = keep + +if activation == "hook": + if first is not None: + was = first.get("command") + first["command"] = hook_cmd + if matches > 1: + result = "deduped" + elif was != hook_cmd: + result = "rewired" + else: + result = "already_present" else: + h = settings.setdefault("hooks", {}) + if not isinstance(h, dict): + h = settings["hooks"] = {} + ups = h.setdefault("UserPromptSubmit", []) + if not isinstance(ups, list): + ups = h["UserPromptSubmit"] = [] ups.append({"hooks": [{"type": "command", "command": hook_cmd}]}) result = "added" +else: + result = "removed" if matches else "already_absent" + +# Prune only the containers this run emptied. An unrelated hook group, event or +# settings key is left exactly as it was. +hooks = settings.get("hooks") +if isinstance(hooks, dict) and isinstance(hooks.get("UserPromptSubmit"), list): + pruned = [g for g in hooks["UserPromptSubmit"] + if (isinstance(g, dict) and g.get("hooks")) or id(g) not in emptied] + if pruned: + hooks["UserPromptSubmit"] = pruned + else: + hooks.pop("UserPromptSubmit", None) + if not hooks: + settings.pop("hooks", None) -os.makedirs(os.path.dirname(settings_path) or ".", exist_ok=True) -with open(settings_path, "w") as f: - json.dump(settings, f, indent=2) - f.write("\n") +new = json.dumps(settings, indent=2) + "\n" +# Write only when the bytes actually change, so every re-run is byte-idempotent +# and a no-op run leaves mtimes alone. Never create a settings file just to +# record that there was nothing to remove. +if new != old and not (old is None and not settings): + os.makedirs(os.path.dirname(settings_path) or ".", exist_ok=True) + with open(settings_path, "w") as f: + f.write(new) +elif result in ("added", "rewired", "deduped", "removed"): + result = "already_present" if activation == "hook" else "already_absent" print(result) PYEOF - ) -fi +} -case "$hook_result" in - added) info "hook wired into $SETTINGS_PATH (UserPromptSubmit)" ;; - rewired) info "stale hook path rewritten in $SETTINGS_PATH (UserPromptSubmit)" ;; - already_present) info "hook already present in $SETTINGS_PATH — no change" ;; - removed) info "hook removed from $SETTINGS_PATH — wdym runs on demand only" ;; - already_absent) info "no hook in $SETTINGS_PATH — already on demand only" ;; - skipped_global) info "no change to $SETTINGS_PATH — a global wdym hook already fires in this project" ;; - *) err "could not update hook — check $SETTINGS_PATH manually"; exit 1 ;; -esac - -# --- Init: write the trust-anchor contract into CLAUDE.md -------------------- +report_hook() { + local result="$1" path="$2" + case "$result" in + added) info "hook wired into $path (UserPromptSubmit)" ;; + rewired) info "stale hook path rewritten in $path (UserPromptSubmit)" ;; + deduped) info "duplicate wdym hooks collapsed to one in $path (UserPromptSubmit)" ;; + already_present) info "hook already present in $path — no change" ;; + removed) info "hook removed from $path — wdym runs on demand only" ;; + already_absent) info "no hook in $path — already on demand only" ;; + skipped_global) info "no change to $path — a global wdym hook already fires in this project" ;; + *) err "could not update hook — check $path manually"; exit 1 ;; + esac +} + +# --- Init: write the trust-anchor contract ----------------------------------- # Without this, a fresh install ships the hook but not the trusted authority # that authorises the auto-invoke — leaving the block's signal to fend for # itself and get refused as an injection. Marker-delimited + idempotent: append # once, never duplicate, leave any surrounding memory untouched. -contract_result=$(python3 - "$CLAUDE_MD_PATH" "$ACTIVATION" <<'PYEOF' -import sys, os +# +# The file differs per host (Claude Code reads CLAUDE.md, Codex reads AGENTS.md) +# and so does the command prefix (/wdym vs $wdym), but the contract is the same. +write_contract() { + python3 - "$1" "$2" "$3" <<'PYEOF' +import sys, os, re -path, activation = sys.argv[1], sys.argv[2] +path, activation, prefix = sys.argv[1], sys.argv[2], sys.argv[3] START = "" END = "" @@ -406,7 +520,7 @@ The `` block is emitted by a user-installed `UserPromptSubmit` ho A `` block present (any verdict) ⇒ invoke the `wdym` skill **before** processing the prompt; no block ⇒ respond normally. -Activation is set by `activation` in wdym's `pref.json` ({state}) and changed with `/wdym --init --hook` / `--on-demand`, which moves the hook wiring with it. +Activation is set by `activation` in wdym's `pref.json` ({state}) and changed with `{prefix} --init --hook` / `--on-demand`, which moves the hook wiring with it. {END}""" existing = "" @@ -419,9 +533,27 @@ if os.path.exists(path): # earlier install actively contradicts the live setting — and a contract that # says "there is no hook" while a hook fires suppresses the skill entirely. # Everything outside the markers is left byte-for-byte alone. -if START in existing and END in existing: - head, _, rest = existing.partition(START) - _, _, tail = rest.partition(END) +pattern = re.compile(re.escape(START) + ".*?" + re.escape(END), re.S) +# Removing a duplicate has to close the gap it leaves rather than stranding the +# blank lines that surrounded it, or every re-run over a hand-pasted duplicate +# grows another blank line in the user's memory file. +greedy = re.compile(r"\n*" + re.escape(START) + ".*?" + re.escape(END) + r"\n*", re.S) + + +def strip_blocks(text): + def repl(m): + before = m.start() > 0 + after = m.end() < len(text) + return "\n\n" if before and after else ("\n" if before else "") + return greedy.sub(repl, text) + + +found = list(pattern.finditer(existing)) +if found: + # Refresh the first block in place; drop any later duplicates a hand-paste + # left behind, so the file can never state two different activations. + head = existing[:found[0].start()] + tail = strip_blocks(existing[found[0].end():]) updated = head + BLOCK + tail if updated == existing: print("already_current") @@ -437,30 +569,199 @@ with open(path, "a", encoding="utf-8") as f: f.write(sep + BLOCK + "\n") print("added") PYEOF -) +} + +report_contract() { + local result="$1" path="$2" + case "$result" in + added) info "trust-anchor contract written to $path" ;; + refreshed) info "trust-anchor contract updated in $path (activation: $ACTIVATION)" ;; + already_current) info "trust-anchor contract already current in $path — no change" ;; + *) err "could not write trust-anchor contract — add it to $path manually"; exit 1 ;; + esac +} + +# --- Host install: Claude Code ----------------------------------------------- +install_claude() { + echo "Claude Code ($SCOPE scope, $ACTIVATION activation)" + + local mode + mode="$(install_skill_files "$TARGET_DIR")" + INSTALLED_DIRS="$INSTALLED_DIRS:$TARGET_DIR" + if [[ "$mode" == "symlink-kept" ]]; then + info "$TARGET_DIR is a symlink (dev install) — left in place, no copy" + else + info "skill files installed at $TARGET_DIR" + fi + verify_hook_parses "$TARGET_DIR" + + local pref_result + pref_result="$(write_pref "$PREF_PATH" "$ACTIVATION")" || { err "could not write $PREF_PATH"; exit 1; } + info "pref.json $pref_result" + + # The command carries the absolute skill path so it resolves from any cwd. + local hook_cmd="python3 \"$TARGET_DIR/hooks/prompt-detect.py\"" + + # Local scope: if a wdym hook already fires globally for every project (any + # prompt-detect.py, not just this TARGET_DIR's copy), wiring another one here + # would fire the hook twice per prompt — Claude Code merges hook lists across + # settings.json and settings.local.json, it doesn't dedupe by command text + # across files. Local scope only needs its own pref.json to override the + # global default (local-overrides-global pref resolution already handles + # that), so skip the hook wire entirely in that case. + local skip_hook=0 + if [[ "$SCOPE" == "local" && "$ACTIVATION" == "hook" ]]; then + local global_settings="$CLAUDE_HOME/settings.json" + if [[ -f "$global_settings" ]] && python3 - "$global_settings" <<'PYEOF' +import sys, json +try: + with open(sys.argv[1]) as f: + settings = json.load(f) +except (OSError, json.JSONDecodeError): + sys.exit(1) +for group in settings.get("hooks", {}).get("UserPromptSubmit", []): + for h in group.get("hooks", []): + if "/hooks/prompt-detect.py" in h.get("command", ""): + sys.exit(0) +sys.exit(1) +PYEOF + then + skip_hook=1 + fi + fi + + local hook_result + if [[ "$skip_hook" -eq 1 ]]; then + hook_result="skipped_global" + else + hook_result="$(wire_hook "$SETTINGS_PATH" "$hook_cmd" "$ACTIVATION")" || hook_result="failed" + fi + report_hook "$hook_result" "$SETTINGS_PATH" + + local contract_result + contract_result="$(write_contract "$CLAUDE_MD_PATH" "$ACTIVATION" "/wdym")" || contract_result="failed" + report_contract "$contract_result" "$CLAUDE_MD_PATH" +} -case "$contract_result" in - added) info "trust-anchor contract written to $CLAUDE_MD_PATH" ;; - refreshed) info "trust-anchor contract updated in $CLAUDE_MD_PATH (activation: $ACTIVATION)" ;; - already_current) info "trust-anchor contract already current in $CLAUDE_MD_PATH — no change" ;; - *) err "could not write trust-anchor contract — add it to $CLAUDE_MD_PATH manually"; exit 1 ;; -esac +# --- Host install: Codex ----------------------------------------------------- +CODEX_SKILL_MODE="symlink" + +install_codex() { + echo "Codex (global scope, $ACTIVATION activation)" + + # The canonical copy lives under ~/.claude/skills so both hosts read one tree. + if [[ "$INSTALLED_DIRS" == *":$CANONICAL_DIR"* ]]; then + info "skill files: sharing the canonical copy at $CANONICAL_DIR" + else + local mode + mode="$(install_skill_files "$CANONICAL_DIR")" + INSTALLED_DIRS="$INSTALLED_DIRS:$CANONICAL_DIR" + if [[ "$mode" == "symlink-kept" ]]; then + info "$CANONICAL_DIR is a symlink (dev install) — left in place, no copy" + else + info "skill files installed at $CANONICAL_DIR" + fi + verify_hook_parses "$CANONICAL_DIR" + fi + + # Codex only scans ~/.agents/skills, so expose the canonical copy there. + mkdir -p "$AGENTS_SKILLS_DIR" + if [[ "$COPY" -eq 1 ]]; then + if diff -rq "$CANONICAL_DIR" "$CODEX_LINK_PATH" >/dev/null 2>&1 && [[ ! -L "$CODEX_LINK_PATH" ]]; then + info "skill copy at $CODEX_LINK_PATH already matches the canonical copy" + else + rm -rf "$CODEX_LINK_PATH" + cp -R "$CANONICAL_DIR" "$CODEX_LINK_PATH" + info "skill copied to $CODEX_LINK_PATH (--copy)" + fi + CODEX_SKILL_MODE="copy" + elif [[ -L "$CODEX_LINK_PATH" ]] && [[ "$(cd "$CODEX_LINK_PATH" 2>/dev/null && pwd -P)" == "$(cd "$CANONICAL_DIR" 2>/dev/null && pwd -P)" ]]; then + info "skill symlink already points at $CANONICAL_DIR — no change" + else + rm -rf "$CODEX_LINK_PATH" + if ln -s "$CANONICAL_DIR" "$CODEX_LINK_PATH" 2>/dev/null; then + info "skill symlinked: $CODEX_LINK_PATH -> $CANONICAL_DIR" + else + # Some filesystems and locked-down machines refuse symlinks. A real copy + # still works; it just needs drift-checking on every later run. + cp -R "$CANONICAL_DIR" "$CODEX_LINK_PATH" + CODEX_SKILL_MODE="copy" + warn "symlink refused — copied the skill to $CODEX_LINK_PATH instead" + warn "the copy can drift from the canonical tree; re-run this installer to refresh it" + fi + fi + + # One canonical pref, shared with Claude Code. Never a second Codex pref — + # the two hosts must not be able to disagree about mode or activation. + local pref_result + pref_result="$(write_pref "$CODEX_PREF_PATH" "$ACTIVATION" "$CODEX_SKILL_MODE")" \ + || { err "could not write $CODEX_PREF_PATH"; exit 1; } + if [[ "$WANT_CLAUDE" -eq 1 && "$SCOPE" == "global" ]]; then + info "pref.json shared with Claude Code at $CODEX_PREF_PATH (skill mode: $CODEX_SKILL_MODE)" + else + info "pref.json $pref_result at $CODEX_PREF_PATH" + fi + + local hook_cmd="python3 \"$CANONICAL_DIR/hooks/prompt-detect.py\"" + local hook_result + hook_result="$(wire_hook "$CODEX_HOOKS_PATH" "$hook_cmd" "$ACTIVATION")" || hook_result="failed" + report_hook "$hook_result" "$CODEX_HOOKS_PATH" + + local contract_result + contract_result="$(write_contract "$CODEX_AGENTS_MD" "$ACTIVATION" "\$wdym")" || contract_result="failed" + report_contract "$contract_result" "$CODEX_AGENTS_MD" +} + +# --- Codex trust notice ------------------------------------------------------ +RULE="$(printf '─%.0s' {1..64})" + +trust_notice() { + echo + echo "$RULE" + echo "ACTION REQUIRED — Codex only" + echo + echo "Run /hooks in Codex and approve the wdym hook." + echo + echo "Codex trusts hooks by the contents of the hooks file. This install" + echo "changed that file, so any approval you gave before no longer applies." + echo + echo "Until you approve it, wdym will not run — and Codex will not warn you." + echo "Your prompts will simply pass through unchanged." + echo + echo 'To confirm it worked: submit a prompt, then run "$wdym --status".' + echo "$RULE" +} + +# --- Run --------------------------------------------------------------------- +[[ "$WANT_CLAUDE" -eq 1 ]] && install_claude +if [[ "$WANT_CODEX" -eq 1 ]]; then + [[ "$WANT_CLAUDE" -eq 1 ]] && echo + install_codex +fi echo -ok "wdym installed and initialised ($SCOPE scope, $ACTIVATION activation)" +ok "wdym installed and initialised ($HOST_LABEL, $ACTIVATION activation)" echo if [[ "$ACTIVATION" == "hook" ]]; then where="across all projects" [[ "$SCOPE" == "global" ]] || where="in $PROJECT_DIR" echo "The skill fires automatically on every substantive prompt $where." - echo "To make it manual instead, run \"./install.sh --on-demand\" (or \"/wdym --init --on-demand\")." + echo "To make it manual instead, re-run with \"--on-demand\" (or \"/wdym --init --on-demand\")." else echo "The skill runs only when you invoke it: \"/wdym \", or by asking to improve a prompt." - echo "To make it fire on every prompt, run \"./install.sh --hook\" (or \"/wdym --init --hook\")." + echo "To make it fire on every prompt, re-run with \"--hook\" (or \"/wdym --init --hook\")." fi -if [[ "$SCOPE" == "global" ]]; then - echo "To scope it to one project instead, cd into it and run \"./install.sh --local\" (or \"/wdym --init --local\")." -else - echo "To install it for every project, run \"./install.sh --global\"." - echo "Local settings live in .claude/settings.local.json — keep it out of version control." +if [[ "$WANT_CLAUDE" -eq 1 ]]; then + if [[ "$SCOPE" == "global" ]]; then + echo "To scope it to one project instead, cd into it and re-run with \"--claude --local\"." + else + echo "To install it for every project, re-run with \"--global\"." + echo "Local settings live in .claude/settings.local.json — keep it out of version control." + fi fi + +# Printed last, on every Codex-touching run, so it cannot be mistaken for log +# output or suppressed as "already seen". Never on a Claude-only run. +[[ "$WANT_CODEX" -eq 1 ]] && trust_notice + +exit 0 diff --git a/llms.txt b/llms.txt index 5aae41e..0db5642 100644 --- a/llms.txt +++ b/llms.txt @@ -27,7 +27,7 @@ lazily — the SKILL.md body is re-injected on every run, so it stays small on p - [refs/categories.json](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/categories.json): the type taxonomy — keyword/regex scoring the hook and skill read (user-editable) - [refs/principles/](https://raw.githubusercontent.com/ndisisnd/wdym/main/refs/principles/principles-global.md): principle files, one per type (global always loaded, plus code/question/text-gen) - [hooks/prompt-detect.py](https://raw.githubusercontent.com/ndisisnd/wdym/main/hooks/prompt-detect.py): the UserPromptSubmit pre-scorer that emits the block -- [bin/wdym-prompt.js](https://raw.githubusercontent.com/ndisisnd/wdym/main/bin/wdym-prompt.js): the `npx wdym-prompt` installer — host detection, skill files, hook wiring, trust contract, --doctor / --uninstall +- [install.sh](https://raw.githubusercontent.com/ndisisnd/wdym/main/install.sh): the installer — host detection (Claude Code and/or Codex), skill files, hook wiring, trust contract, pref.json ## Optional diff --git a/package.json b/package.json deleted file mode 100644 index f327fa5..0000000 --- a/package.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "name": "wdym-prompt", - "version": "1.2.0", - "description": "Install and wire wdym — the prompt rewriter that fires on every prompt — for Claude Code and Codex.", - "keywords": [ - "claude-code", - "codex", - "skill", - "prompt", - "prompt-engineering", - "userpromptsubmit", - "hook" - ], - "homepage": "https://github.com/ndisisnd/wdym#readme", - "bugs": { - "url": "https://github.com/ndisisnd/wdym/issues" - }, - "repository": { - "type": "git", - "url": "git+https://github.com/ndisisnd/wdym.git" - }, - "license": "MIT", - "author": "ndisisnd ", - "type": "commonjs", - "bin": { - "wdym-prompt": "bin/wdym-prompt.js" - }, - "engines": { - "node": ">=18" - }, - "files": [ - "bin/", - "hooks/", - "refs/", - "agents/", - "asset/", - "SKILL.md", - "README.md", - "CHANGELOG.md", - "LICENSE.md", - "SECURITY.md", - "llms.txt" - ], - "scripts": { - "test": "python3 tests/detect_bench.py", - "prepublishOnly": "python3 tests/detect_bench.py" - } -}