context-carwash 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - `carwash stats` leads with a likelier saving and shows the old figure as the most it could be. It counts only the first part of the prompts after each wash, since you would have cleared the old conversation at some point anyway. That share starts at two-thirds and is learned from how far your own Claude Code conversations go, read on your machine. Output the fresh conversation had to read again, which the old one already had, is taken off.
6
+ - `stats --share` sends the likely figure as well as the full one.
7
+
8
+ ## 0.2.1
9
+
10
+ - Codex support. `install` sets up Codex too when it finds it: three hooks in `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`. Codex asks you to trust the hooks once. After `/clear`, the brief loads with your next message. Codex's status line can only show its own items, so the meter is a line after each reply instead, in colour from Codex 0.162.0 on. It is shown to you only and costs no tokens.
11
+ - `carwash doctor` checks the Codex hooks, whether Codex trusts them, and `$wash`.
12
+ - `carwash stats` counts Codex sessions too.
13
+ - Claude Code works exactly as before. A brief from one tool is not picked up by the other unless you load it by id.
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  - README demo GIF and a one-line summary at the top.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Context Carwash
2
2
 
3
- *Long Claude Code sessions get slow and forgetful. Run it through the wash: keep what matters, drop the rest, start fresh.*
3
+ *Long Claude Code and Codex sessions get slow and forgetful. Run it through the wash: keep what matters, drop the rest, start fresh.*
4
4
 
5
5
  ![The meter fills up, /wash writes a short brief, and after /clear a fresh session picks up where the old one stopped](docs/demo.gif)
6
6
 
@@ -9,6 +9,8 @@ Context Carwash adds a line to Claude Code in your terminal that:
9
9
  1. **The Meter:** shows the current conversation's context size.
10
10
  2. **/wash:** lets you compress your context and start a new session without losing important details.
11
11
 
12
+ It works in Codex too: see [Codex](#codex).
13
+
12
14
  Pretty simple app, but I've found it helpful. Everything is local, but you can share your token savings anonymously with me if you'd like with `npx context-carwash stats --share`.
13
15
 
14
16
  ## Install
@@ -19,11 +21,11 @@ You'll need Node 18 or later.
19
21
  npx context-carwash install
20
22
  ```
21
23
 
22
- It shows you what it's going to change and asks before doing anything. Then start a new Claude Code session and you're set.
24
+ It shows you what it's going to change and asks before doing anything. Then start a new Claude Code session and you're set. If you use Codex as well, it sets that up in the same go.
23
25
 
24
26
  When there's a new version, a line under the meter will tell you. To update, run `npx context-carwash@latest install`.
25
27
 
26
- Under the hood, it adds a status line and three hooks to `~/.claude/settings.json` (after whatever you already have there) and a `/wash` command in `~/.claude/skills/wash`.
28
+ Under the hood, it adds a status line and three hooks to `~/.claude/settings.json` (after whatever you already have there) and a `/wash` command in `~/.claude/skills/wash`. For Codex, it adds three hooks to `~/.codex/hooks.json` and a `$wash` command in `~/.codex/skills/wash`.
27
29
 
28
30
  ## The Meter
29
31
 
@@ -60,6 +62,19 @@ A few things worth knowing:
60
62
  - Closed the terminal before you got to `/clear`? Run `claude` in the same folder and your brief loads. If there's more than one waiting, it'll list them so you can pick with `/wash load <id>`.
61
63
  - Briefs expire after 48 hours.
62
64
 
65
+ ## Codex
66
+
67
+ If you have Codex, `install` sets it up too. It works the same way, with a few differences:
68
+
69
+ - The command is `$wash` (Codex keeps `/` for its own commands). Same options: `$wash light`, `$wash discard`, `$wash help` and so on.
70
+ - Codex asks you to approve new hooks before it runs them. The first time you start Codex after installing, choose **Trust all and continue**.
71
+ - After `/clear`, send your next message (even just "continue"). The brief loads with it, because that's when Codex starts the new session.
72
+ - The meter shows up as a line after each reply, because Codex's status line can only show its own items:
73
+ `session context ~70k [████░░░░░░░░░░░] $wash to compress, then /clear`
74
+ The bar is in colour from Codex 0.162.0 on (plain text before that, or with `NO_COLOR`). It's shown to you only, never sent to the model, so it costs no tokens.
75
+ - A brief written in Claude Code isn't picked up by Codex on its own, and the other way round. To use one anyway, run `$wash load <id>` (or `/wash load <id>`).
76
+ - `codex exec` runs never use up a brief.
77
+
63
78
  ## Stats
64
79
 
65
80
  ```sh
@@ -72,12 +87,16 @@ Carwash stats, since 3 Oct 2026 (12 days)
72
87
  Briefs written 7 (light 1 · mid 5 · detailed 1)
73
88
  Started fresh with one 6
74
89
  Prompts in fresh sessions 94 (410 requests to Claude)
75
- Context not re-sent ~21M tokens
90
+ Context not re-sent ~11M tokens (at most ~21M tokens)
76
91
  Spent writing briefs ~0.9M tokens
77
- Net saved ~20M tokens
92
+ Net saved ~10M tokens (at most ~20M tokens)
78
93
  ```
79
94
 
80
- Every request to Claude sends the whole conversation again. After a wash, each request starts from the small brief instead, so Carwash counts the requests you actually made and adds up the difference. Claude charges about a tenth of the price for repeated context, so in dollars you're saving roughly a tenth of these numbers. You can also see this inside Claude Code with `/wash stats`.
95
+ Every request to Claude or Codex sends the whole conversation again. After a wash, each request starts from the small brief instead, so Carwash counts the requests you actually made and adds up the difference. Counting stops where the old conversation would have filled up and compacted on its own.
96
+
97
+ You wouldn't have kept going in the old conversation forever, so the main number only counts the first part of the prompts after each wash. That's two-thirds to begin with. Carwash then adjusts it by looking at how far your own past conversations went once they were that big, so if you usually clear around 300k, a wash at 300k counts for less. Anything the fresh conversation had to read again, like a file the old one already had open, is taken off too. "At most" counts every prompt. All of this is worked out on your computer from Claude Code's own history files, and only a few numbers per conversation are kept.
98
+
99
+ Repeated context is charged at about a tenth of the price, so in dollars you're saving roughly a tenth of these numbers. You can also see this inside Claude Code with `/wash stats`.
81
100
 
82
101
  If you run `stats --share`, it shows you exactly what it would send before asking.
83
102
 
@@ -99,11 +118,11 @@ Your briefs, stats and error logs live in `~/.carwash`, and only you can read th
99
118
  npx context-carwash uninstall
100
119
  ```
101
120
 
102
- This puts `settings.json` back exactly as it was. If you've edited the file since installing, it only removes Carwash's status line and hooks and leaves your changes alone. It also removes the `/wash` command and deletes `~/.carwash`, including any waiting briefs and your stats.
121
+ This puts `settings.json` back exactly as it was. If you've edited the file since installing, it only removes Carwash's status line and hooks and leaves your changes alone. Codex's `hooks.json` gets the same treatment. It also removes the `/wash` and `$wash` commands and deletes `~/.carwash`, including any waiting briefs and your stats.
103
122
 
104
123
  ## Platforms
105
124
 
106
- Built and tested on macOS. Automated tests also run on Linux and Windows, but I haven't tried those by hand yet.
125
+ Built and tested on macOS, with Claude Code and with Codex. Automated tests also run on Linux and Windows, but I haven't tried those by hand yet.
107
126
 
108
127
  ## License
109
128
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "context-carwash",
3
- "version": "0.2.0",
4
- "description": "A quiet context meter and deliberate fresh-session handoff for Claude Code.",
3
+ "version": "0.2.2",
4
+ "description": "A quiet context meter and deliberate fresh-session handoff for Claude Code and Codex.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  "keywords": [
13
13
  "claude",
14
14
  "claude-code",
15
+ "codex",
15
16
  "context",
16
17
  "statusline"
17
18
  ],
package/src/cli.mjs CHANGED
@@ -1,23 +1,22 @@
1
1
  import { appendFileSync, existsSync, statSync, writeFileSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { applyInstall, applyUninstall, describeInstall, describeUninstall, packageVersion, planInstall, planUninstall } from './install/install.mjs';
4
- import { claudeVersion, doctorReport, markErrorsSeen } from './install/doctor.mjs';
4
+ import { claudeVersion, codexInScope, codexVersion, doctorReport, markErrorsSeen } from './install/doctor.mjs';
5
5
  import { fetchNotice, refreshNotice, startCheck } from './install/notice.mjs';
6
6
  import { UserError } from './install/settings.mjs';
7
7
  import { isNewer, latestVersion, runRelease } from './install/update.mjs';
8
8
  import { errorLogPath, installRecordPath, welcomedPath } from './paths.mjs';
9
9
  import { readJson } from './files.mjs';
10
- import { refreshLedger, totals } from './stats/ledger.mjs';
11
- import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport } from './stats/report.mjs';
10
+ import { FORM, describeShare, sendShare, shareId, sharePayload, shareReady, statsReport, currentTotals } from './stats/report.mjs';
12
11
  import { render } from './statusline/render.mjs';
13
12
  import { statusline } from './statusline/run.mjs';
14
13
  import { runHook } from './wash/hooks.mjs';
15
14
 
16
- const HELP = `Context Carwash: a quiet context meter and a deliberate fresh start for Claude Code.
15
+ const HELP = `Context Carwash: a quiet context meter and a deliberate fresh start for Claude Code and Codex.
17
16
 
18
17
  Usage: carwash <command>
19
18
 
20
- install [--yes] Add the context meter and the /wash command to Claude Code
19
+ install [--yes] Add the context meter and /wash to Claude Code, and $wash to Codex if you have it
21
20
  uninstall [--yes] Undo everything install did
22
21
  preview Print sample meters in this terminal
23
22
  stats [--share] Show how much context /wash has saved; --share sends the totals anonymously, after asking
@@ -33,6 +32,9 @@ Inside Claude Code, once installed:
33
32
  /wash stats The same totals as carwash stats
34
33
  /wash help Explain the meter and the options
35
34
 
35
+ In Codex it is $wash, with the same options. After /clear, the brief loads
36
+ with the first message you send. Codex shows the meter after each reply.
37
+
36
38
  Nothing happens automatically, and nothing about you is ever sent. Once a day a
37
39
  new session reads Carwash's public page on npm for the newest version and any
38
40
  warning about yours (CARWASH_NO_UPDATE_CHECK=1 turns that off). install and
@@ -99,7 +101,16 @@ function logError(env, error) {
99
101
  }
100
102
  }
101
103
 
102
- export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease, form = FORM, share = sendShare, notice = fetchNotice, claude = claudeVersion, check = startCheck } = {}) {
104
+ // What to do next, after an install, for each tool it covered.
105
+ function installedMessage(plan, env) {
106
+ const welcome = existsSync(welcomedPath(env, 'claude')) ? '' : ' Its first message explains them.';
107
+ const codex = 'choose "Trust all and continue" when it asks about new hooks. Then type $wash when you want a fresh start.';
108
+ if (!plan.codex) return `Installed. Start a new Claude Code session to get the meter and /wash.${welcome}`;
109
+ if (!plan.claude) return `Installed. Start Codex and ${codex}`;
110
+ return ['Installed.', ` Claude Code: start a new session to get the meter and /wash.${welcome}`, ` Codex: start a new session and ${codex}`].join('\n');
111
+ }
112
+
113
+ export async function main(argv, { env = process.env, stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, latest = latestVersion, handOff = runRelease, form = FORM, share = sendShare, notice = fetchNotice, claude = claudeVersion, codex = codexVersion, check = startCheck } = {}) {
103
114
  const [command, ...rest] = argv;
104
115
  const yes = rest.includes('--yes') || rest.includes('-y');
105
116
  try {
@@ -111,7 +122,8 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
111
122
  let output = null;
112
123
  try {
113
124
  const version = packageVersion();
114
- output = runHook({ input: await readAll(stdin), env, version, onError: (error) => logError(env, error), checkUpdates: () => check(version, { env }) });
125
+ // `carwash hook` is Claude Code's; Codex's hooks run `carwash hook codex`.
126
+ output = runHook({ input: await readAll(stdin), tool: rest[0] ?? 'claude', env, version, onError: (error) => logError(env, error), checkUpdates: () => check(version, { env }) });
115
127
  } catch (error) {
116
128
  logError(env, error);
117
129
  }
@@ -141,8 +153,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
141
153
  applyInstall(plan);
142
154
  // Errors from before this install are not news.
143
155
  markErrorsSeen(env);
144
- const welcome = existsSync(welcomedPath(env)) ? '' : ' Its first message explains them.';
145
- stdout.write(`Installed. Start a new Claude Code session to get the meter and /wash.${welcome}\n`);
156
+ stdout.write(`${installedMessage(plan, env)}\n`);
146
157
  return 0;
147
158
  }
148
159
  case 'uninstall': {
@@ -157,7 +168,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
157
168
  return 0;
158
169
  }
159
170
  case 'stats': {
160
- const sum = totals(refreshLedger(env, { force: true }));
171
+ const sum = currentTotals(env);
161
172
  stdout.write(statsReport(sum) + '\n');
162
173
  if (!rest.includes('--share')) return 0;
163
174
  stdout.write('\n');
@@ -189,7 +200,7 @@ export async function main(argv, { env = process.env, stdin = process.stdin, std
189
200
  const running = packageVersion();
190
201
  const installed = readJson(installRecordPath(env))?.version;
191
202
  const newest = await notice(typeof installed === 'string' ? installed : running, { env });
192
- stdout.write(doctorReport({ env, running, newest, claude: claude({ env }) }) + '\n');
203
+ stdout.write(doctorReport({ env, running, newest, claude: claude({ env }), codex: codexInScope(env) ? codex({ env }) : null }) + '\n');
193
204
  markErrorsSeen(env);
194
205
  return 0;
195
206
  }
@@ -0,0 +1,256 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, rmdirSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join, sep } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { privateDir, writeFileAtomic } from '../files.mjs';
6
+ import { codexConfigPath, codexHome, codexHooksPath, codexSkillDir } from '../paths.mjs';
7
+ import { UserError, readSettings, resolveSettingsFile, serializeSettings, sha256 } from './settings.mjs';
8
+ import { commandFor, withoutCarwashHooks } from './install.mjs';
9
+
10
+ // Carwash in Codex: three hooks in Codex's hooks file (~/.codex/hooks.json)
11
+ // and the $wash skill in ~/.codex/skills/wash. Codex's own config.toml is
12
+ // never written. Codex asks the person to trust new hooks before it runs them,
13
+ // and keeps that answer in config.toml; Carwash only reads it, for doctor.
14
+ //
15
+ // The hooks file has the same shape as Claude Code's `hooks` setting, so it is
16
+ // added to and taken out of the same way, and put back byte-for-byte when it
17
+ // has not changed since install.
18
+
19
+ const SKILL_SOURCE = fileURLToPath(new URL('../skill/codex', import.meta.url));
20
+ const SKILL_FILES = ['SKILL.md', join('agents', 'openai.yaml')];
21
+ const SKILL_MARK = 'installed-by: context-carwash';
22
+
23
+ // What each hook is narrowed to. Codex ignores matchers on UserPromptSubmit
24
+ // and Stop, so those run for every prompt and every reply, and return at once
25
+ // when there is no $wash. SessionStart passes the brief through whole: by
26
+ // default Codex saves context over 2,500 tokens to a file and shows the model
27
+ // only the start and end. The status message is the name Codex shows when it
28
+ // asks the person to trust the hooks, and beside one that runs slowly.
29
+ export const CODEX_HOOKS = {
30
+ SessionStart: { group: { matcher: 'startup|clear' }, handler: { additionalContextLimit: 0 } },
31
+ UserPromptSubmit: { group: {}, handler: {} },
32
+ Stop: { group: {}, handler: {} },
33
+ };
34
+ const HOOK_TIMEOUT_SECONDS = 20;
35
+ const HOOK_NAME = 'Context Carwash';
36
+
37
+ export const isCarwashCodexHook = (command) => typeof command === 'string' && /carwash\.mjs['"]?\s+hook\s+codex\s*$/.test(command);
38
+
39
+ const tilde = (path) => (path.startsWith(homedir() + sep) ? `~${path.slice(homedir().length)}` : path);
40
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
41
+
42
+ // Whether Codex is set up on this machine: it makes its home folder the first
43
+ // time it runs.
44
+ export const codexDetected = (env = process.env) => existsSync(codexHome(env));
45
+
46
+ function withCarwashCodexHooks(hooks, command, file) {
47
+ const refuse = () => {
48
+ throw new UserError(`${tilde(file)} has a "hooks" setting that Carwash does not know how to add to. Nothing was changed.`);
49
+ };
50
+ if (hooks !== undefined && !isPlainObject(hooks)) refuse();
51
+ const next = { ...(withoutCarwashHooks(hooks, isCarwashCodexHook) ?? {}) };
52
+ for (const [event, { group, handler }] of Object.entries(CODEX_HOOKS)) {
53
+ if (next[event] !== undefined && !Array.isArray(next[event])) refuse();
54
+ next[event] = [...(next[event] ?? []), { ...group, hooks: [{ type: 'command', command, timeout: HOOK_TIMEOUT_SECONDS, statusMessage: HOOK_NAME, ...handler }] }];
55
+ }
56
+ return next;
57
+ }
58
+
59
+ function isOurSkill(file) {
60
+ try {
61
+ return readFileSync(file, 'utf8').includes(SKILL_MARK);
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ // Hooks written straight into config.toml, which Codex also reads. Approvals
68
+ // live under [hooks.state] and do not count.
69
+ function inlineHooks(env) {
70
+ try {
71
+ return /^\s*\[\[?\s*hooks\.(?!state\b)/m.test(readFileSync(codexConfigPath(env), 'utf8'));
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ // Work out what installing in Codex would change, without changing anything.
78
+ // `script` is where the installed copy of Carwash will be; `previous` is what
79
+ // an earlier install recorded about Codex, if anything.
80
+ export function planCodexInstall({ env = process.env, script, previous = null }) {
81
+ const file = resolveSettingsFile(codexHooksPath(env));
82
+ const settings = readSettings(file);
83
+ const next = { ...settings.data, hooks: withCarwashCodexHooks(settings.data.hooks, commandFor('hook codex', script), file) };
84
+ const dir = codexSkillDir(env);
85
+ const skillFile = join(dir, 'SKILL.md');
86
+ if (existsSync(skillFile) && !isOurSkill(skillFile)) {
87
+ throw new UserError(`You already have a Codex skill called wash at ${tilde(dir)}, and Carwash needs that name. Nothing was changed. Rename or remove yours, then run install again.`);
88
+ }
89
+ const theirHooks = withoutCarwashHooks(settings.data.hooks, isCarwashCodexHook);
90
+ return {
91
+ file,
92
+ settings,
93
+ next,
94
+ previous,
95
+ skill: { dir, file: skillFile },
96
+ keepsHooks: isPlainObject(theirHooks) && Object.keys(theirHooks).length > 0,
97
+ inlineHooks: inlineHooks(env),
98
+ };
99
+ }
100
+
101
+ // The install steps for Codex, numbered from `first`. `home` is Carwash's own
102
+ // folder, where a backup goes.
103
+ export function describeCodexInstall(plan, first, home) {
104
+ const lines = [];
105
+ lines.push(` ${first}. Codex: ${plan.settings.exists ? 'edit' : 'create'} ${tilde(plan.file)}`);
106
+ lines.push(' Adds three hooks, so $wash can save a brief and a fresh session can pick it up.');
107
+ if (plan.keepsHooks) lines.push(' The hooks you already have there are kept as they are.');
108
+ lines.push(' Codex asks you to trust new hooks before it runs them: the next time it');
109
+ lines.push(' starts, choose "Trust all and continue".');
110
+ if (plan.inlineHooks) lines.push(' You also have hooks in config.toml, so Codex will note hooks in two places. Both keep working.');
111
+ if (plan.settings.exists && !plan.previous) lines.push(` A backup of the file is kept in ${tilde(join(home, 'backup'))}`);
112
+ lines.push(` ${first + 1}. Codex: add the $wash command at ${tilde(plan.skill.dir)}`);
113
+ return lines;
114
+ }
115
+
116
+ export function applyCodexInstall(plan, home) {
117
+ const { file, settings, previous, skill } = plan;
118
+ for (const name of SKILL_FILES) {
119
+ mkdirSync(dirname(join(skill.dir, name)), { recursive: true });
120
+ copyFileSync(join(SKILL_SOURCE, name), join(skill.dir, name));
121
+ }
122
+
123
+ // The first install records what was there; later runs must not overwrite it.
124
+ let backupPath = previous?.backupPath ?? null;
125
+ if (!previous && settings.exists) {
126
+ privateDir(join(home, 'backup'));
127
+ backupPath = join(home, 'backup', 'codex-hooks.json');
128
+ writeFileSync(backupPath, settings.bytes, { mode: 0o600 });
129
+ }
130
+
131
+ const bytes = serializeSettings(plan.next, settings);
132
+ writeFileAtomic(file, bytes, settings.exists ? undefined : 0o600);
133
+ const pristine = !previous || (settings.exists && previous.installedSha256 === sha256(settings.bytes));
134
+ return {
135
+ hooksPath: file,
136
+ hadHooksFile: previous ? previous.hadHooksFile : settings.exists,
137
+ backupPath,
138
+ originalSha256: previous ? previous.originalSha256 : settings.exists ? sha256(settings.bytes) : null,
139
+ installedSha256: pristine ? sha256(bytes) : null,
140
+ skillDir: skill.dir,
141
+ };
142
+ }
143
+
144
+ // Work out what uninstalling from Codex would change. `record` is what install
145
+ // noted about Codex, or null (no record, or an install from before Codex
146
+ // support): then only Carwash's own entries are looked for and taken out.
147
+ export function planCodexUninstall({ env = process.env, record = null }) {
148
+ const file = resolveSettingsFile(record?.hooksPath ?? codexHooksPath(env));
149
+ const plan = { file, action: 'none', bytes: null };
150
+ if (existsSync(file)) {
151
+ const current = readFileSync(file);
152
+ const backupOk = record?.backupPath && existsSync(record.backupPath) && sha256(readFileSync(record.backupPath)) === record.originalSha256;
153
+ if (record && sha256(current) === record.installedSha256 && !record.hadHooksFile) {
154
+ plan.action = 'delete';
155
+ } else if (record && sha256(current) === record.installedSha256 && backupOk) {
156
+ plan.action = 'restore';
157
+ plan.bytes = readFileSync(record.backupPath);
158
+ } else {
159
+ // The file changed after install: undo only our own entries and keep the rest.
160
+ const settings = readSettings(file);
161
+ const hooks = withoutCarwashHooks(settings.data.hooks, isCarwashCodexHook);
162
+ if (hooks !== settings.data.hooks) {
163
+ const data = { ...settings.data };
164
+ if (hooks === undefined) delete data.hooks;
165
+ else data.hooks = hooks;
166
+ if (!Object.keys(data).length && record && !record.hadHooksFile) {
167
+ plan.action = 'delete';
168
+ } else {
169
+ plan.action = 'edit';
170
+ plan.bytes = serializeSettings(data, settings);
171
+ }
172
+ }
173
+ }
174
+ }
175
+ const dir = record?.skillDir ?? codexSkillDir(env);
176
+ plan.skillDir = dir;
177
+ plan.skill = isOurSkill(join(dir, 'SKILL.md'));
178
+ plan.foreignSkill = !plan.skill && existsSync(join(dir, 'SKILL.md'));
179
+ plan.found = plan.action !== 'none' || plan.skill;
180
+ return plan;
181
+ }
182
+
183
+ export function describeCodexUninstall(plan) {
184
+ const steps = [];
185
+ const what = {
186
+ restore: `Codex: restore ${tilde(plan.file)} to exactly what it was before install`,
187
+ delete: `Codex: delete ${tilde(plan.file)} (Carwash created it and it has not changed since)`,
188
+ edit: `Codex: edit ${tilde(plan.file)}: take out Carwash's hooks; everything else stays`,
189
+ }[plan.action];
190
+ if (what) steps.push([what]);
191
+ if (plan.skill) steps.push([`Codex: remove the $wash command at ${tilde(plan.skillDir)}`]);
192
+ else if (plan.foreignSkill && plan.action !== 'none') steps.push([`Codex: leave the wash skill at ${tilde(plan.skillDir)} alone (not Carwash's)`]);
193
+ return steps;
194
+ }
195
+
196
+ export function applyCodexUninstall(plan) {
197
+ if (plan.action === 'restore' || plan.action === 'edit') writeFileAtomic(plan.file, plan.bytes);
198
+ if (plan.action === 'delete') unlinkSync(plan.file);
199
+ if (plan.skill) {
200
+ for (const name of SKILL_FILES) {
201
+ try {
202
+ unlinkSync(join(plan.skillDir, name));
203
+ } catch {
204
+ // already gone
205
+ }
206
+ }
207
+ for (const dir of [join(plan.skillDir, 'agents'), plan.skillDir]) {
208
+ try {
209
+ rmdirSync(dir);
210
+ } catch {
211
+ // the folder holds something else, which stays
212
+ }
213
+ }
214
+ }
215
+ }
216
+
217
+ // For doctor: which of Carwash's Codex hooks are in the hooks file, and which
218
+ // of those Codex has been told to trust. Codex keys each approval by the file,
219
+ // the event, and the hook's place in the file.
220
+ export function codexHooksCheck(env = process.env) {
221
+ const file = resolveSettingsFile(codexHooksPath(env));
222
+ let data;
223
+ try {
224
+ data = readSettings(file).data;
225
+ } catch {
226
+ return { readable: false, file };
227
+ }
228
+ let config = '';
229
+ try {
230
+ config = readFileSync(codexConfigPath(env), 'utf8');
231
+ } catch {
232
+ // no approvals yet
233
+ }
234
+ const snake = (event) => event.replace(/([a-z])([A-Z])/g, '$1_$2').toLowerCase();
235
+ // Codex may name the file as configured or with links resolved, and TOML
236
+ // doubles the backslashes in a Windows path.
237
+ const names = [...new Set([codexHooksPath(env), file])].flatMap((name) => [name, name.replace(/\\/g, '\\\\')]);
238
+ const keyed = (key) => names.some((name) => config.includes(`[hooks.state."${name}${key}"]`));
239
+ const result = { readable: true, file, hooks: {}, trusted: {}, nodes: [] };
240
+ for (const event of Object.keys(CODEX_HOOKS)) {
241
+ const groups = Array.isArray(data?.hooks?.[event]) ? data.hooks[event] : [];
242
+ result.hooks[event] = false;
243
+ result.trusted[event] = false;
244
+ groups.forEach((group, g) => {
245
+ (Array.isArray(group?.hooks) ? group.hooks : []).forEach((hook, h) => {
246
+ if (!isCarwashCodexHook(hook?.command)) return;
247
+ result.hooks[event] = true;
248
+ result.nodes.push(hook.command);
249
+ if (keyed(`:${snake(event)}:${g}:${h}`)) result.trusted[event] = true;
250
+ });
251
+ });
252
+ }
253
+ return result;
254
+ }
255
+
256
+ export const codexSkillInstalled = (env = process.env) => isOurSkill(join(codexSkillDir(env), 'SKILL.md'));
@@ -6,6 +6,7 @@ import { readJson } from '../files.mjs';
6
6
  import { errorLogPath, errorsSeenPath, installRecordPath, settingsPath, skillDir } from '../paths.mjs';
7
7
  import { refreshLedger, totals } from '../stats/ledger.mjs';
8
8
  import { readyRecords } from '../wash/store.mjs';
9
+ import { codexDetected, codexHooksCheck, codexSkillInstalled } from './codex.mjs';
9
10
  import { commandProgram, isCarwashCommand, isCarwashHook } from './install.mjs';
10
11
  import { UPDATE_COMMAND } from './notice.mjs';
11
12
  import { readSettings, resolveSettingsFile } from './settings.mjs';
@@ -33,6 +34,21 @@ export function claudeVersion({ env = process.env, platform = process.platform }
33
34
  }
34
35
  }
35
36
 
37
+ // Codex's own version, or null if `codex` is not on PATH.
38
+ export function codexVersion({ env = process.env, platform = process.platform } = {}) {
39
+ try {
40
+ const result = spawnSync('codex', ['--version'], { env, encoding: 'utf8', timeout: 5000, shell: platform === 'win32', windowsHide: true });
41
+ const out = (result.stdout ?? '').trim();
42
+ return result.status === 0 && out ? out.split('\n')[0] : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // Whether doctor has anything to say about Codex: it is set up here, or an
49
+ // install covered it.
50
+ export const codexInScope = (env = process.env, record = readJson(installRecordPath(env))) => Boolean(record?.codex) || codexDetected(env);
51
+
36
52
  // The last few entries of the error log, newest last. Each entry starts with
37
53
  // the time it was written; a stack trace follows on the next lines.
38
54
  export function recentErrors(env = process.env) {
@@ -81,8 +97,9 @@ const date = (iso) => {
81
97
 
82
98
  // `running` is this copy's version, which under npx may differ from the copy
83
99
  // installed in ~/.carwash/app. `newest` is what npm said just now: { latest,
84
- // warning } or null.
85
- export function doctorReport({ env = process.env, now = Date.now(), running, newest, claude, platform = process.platform }) {
100
+ // warning } or null. `claude` and `codex` are the tools' versions, or null;
101
+ // `codex` is only asked for when Codex is in scope.
102
+ export function doctorReport({ env = process.env, now = Date.now(), running, newest, claude, codex = null, platform = process.platform }) {
86
103
  const row = (label, value) => ` ${label.padEnd(18)}${value}`;
87
104
  const record = readJson(installRecordPath(env));
88
105
  const installed = typeof record?.version === 'string' ? record.version : null;
@@ -103,19 +120,43 @@ export function doctorReport({ env = process.env, now = Date.now(), running, new
103
120
  out.push(row('Node', process.version));
104
121
  out.push(row('System', `${platform} ${release()} ${arch()}`));
105
122
  out.push(row('Claude Code', claude ?? 'not found on PATH'));
123
+ const withCodex = codexInScope(env, record);
124
+ if (withCodex) out.push(row('Codex', codex ?? 'not found on PATH'));
106
125
 
107
126
  if (installed) {
108
- const check = settingsCheck(env);
109
127
  const yes = (ok) => (ok ? 'ok' : 'MISSING');
110
- if (!check.readable) {
111
- out.push(row('Settings', `could not read ${tilde(settingsPath(env))}`));
112
- } else {
113
- out.push(row('Status line', yes(check.statusLine)));
114
- out.push(row('Hooks', HOOK_EVENTS.map((event) => `${event} ${yes(check.hooks[event])}`).join(' · ')));
115
- const node = nodeRow(check.nodes);
116
- if (node) out.push(row('Node for Carwash', node));
128
+ // Installs from before Codex support do not list their tools: Claude Code.
129
+ const tools = Array.isArray(record.tools) ? record.tools : ['claude'];
130
+ const nodes = [];
131
+ if (tools.includes('claude')) {
132
+ const check = settingsCheck(env);
133
+ if (!check.readable) {
134
+ out.push(row('Settings', `could not read ${tilde(settingsPath(env))}`));
135
+ } else {
136
+ out.push(row('Status line', yes(check.statusLine)));
137
+ out.push(row('Hooks', HOOK_EVENTS.map((event) => `${event} ${yes(check.hooks[event])}`).join(' · ')));
138
+ nodes.push(...check.nodes);
139
+ }
140
+ out.push(row('/wash command', yes(existsSync(join(skillDir(env), 'SKILL.md')))));
141
+ }
142
+ if (tools.includes('codex')) {
143
+ const check = codexHooksCheck(env);
144
+ if (!check.readable) {
145
+ out.push(row('Codex hooks', `could not read ${tilde(check.file)}`));
146
+ } else {
147
+ const events = Object.keys(check.hooks);
148
+ out.push(row('Codex hooks', events.map((event) => `${event} ${yes(check.hooks[event])}`).join(' · ')));
149
+ const present = events.filter((event) => check.hooks[event]);
150
+ if (present.length) {
151
+ const trusted = present.every((event) => check.trusted[event]);
152
+ out.push(row('Codex trust', trusted ? 'ok' : 'not yet: start Codex and choose "Trust all and continue" when it asks about new hooks'));
153
+ }
154
+ nodes.push(...check.nodes.map(commandProgram).filter(Boolean));
155
+ }
156
+ out.push(row('$wash command', yes(codexSkillInstalled(env))));
117
157
  }
118
- out.push(row('/wash command', yes(existsSync(join(skillDir(env), 'SKILL.md')))));
158
+ const node = nodeRow([...new Set(nodes)]);
159
+ if (node) out.push(row('Node for Carwash', node));
119
160
  let waiting = '?';
120
161
  let written = '?';
121
162
  try {