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 +12 -0
- package/README.md +27 -8
- package/package.json +3 -2
- package/src/cli.mjs +22 -11
- package/src/install/codex.mjs +256 -0
- package/src/install/doctor.mjs +52 -11
- package/src/install/install.mjs +91 -31
- package/src/paths.mjs +17 -2
- package/src/skill/codex/SKILL.md +47 -0
- package/src/skill/codex/agents/openai.yaml +3 -0
- package/src/stats/habits.mjs +107 -0
- package/src/stats/ledger.mjs +216 -17
- package/src/stats/report.mjs +32 -7
- package/src/tools.mjs +40 -0
- package/src/wash/brief.mjs +56 -17
- package/src/wash/hooks.mjs +141 -58
- package/src/wash/process.mjs +111 -0
- package/src/wash/rollout.mjs +111 -0
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
|

|
|
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.
|
|
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`
|
|
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.
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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'));
|
package/src/install/doctor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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 {
|