@redue/cli 0.1.0-alpha.22 → 0.1.0-alpha.23
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 +36 -0
- package/README.md +117 -200
- package/bin/redue.mjs +15 -1
- package/docs/user/agents.md +12 -4
- package/docs/user/direct-typescript.md +3 -1
- package/docs/user/getting-started.md +20 -1
- package/docs/user/node-script.md +4 -1
- package/docs/user/supported-projects.md +5 -1
- package/package.json +1 -1
- package/src/agent-integration.mjs +89 -16
- package/src/claude-hook.mjs +23 -6
- package/src/node-script.mjs +15 -0
- package/src/pnpm-inputs.mjs +4 -2
- package/src/setup.mjs +178 -0
- package/src/typescript-list.mjs +8 -0
- package/src/yarn-workspace-inputs.mjs +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.0-alpha.23 — 2026-10-26
|
|
4
|
+
|
|
5
|
+
One command to set up REDUE with Claude Code, and one to undo it.
|
|
6
|
+
|
|
7
|
+
- **`redue setup claude`** runs `init` (when there is no
|
|
8
|
+
`redue.config.json`), connects Claude Code, starts the observer, waits for
|
|
9
|
+
it to index the project, and records each check that can qualify once. It
|
|
10
|
+
asks once (`--yes` for no prompts), prints every file it writes, and a
|
|
11
|
+
second run changes nothing. It stops before writing when dependencies are
|
|
12
|
+
not installed or nothing can be set up.
|
|
13
|
+
- **`redue remove`** undoes exactly what setup created: the integration (or
|
|
14
|
+
only this machine's hooks), the observer state, the local ignore line, and
|
|
15
|
+
the config if it is unchanged since setup wrote it. Anything that existed
|
|
16
|
+
before setup stays. Setup notes what it created in the clone's Git
|
|
17
|
+
directory, never in a committed file.
|
|
18
|
+
- **Hooks find REDUE.** The Claude Code hooks name plain `redue` only when
|
|
19
|
+
the `redue` that your shell and a login shell find is this install;
|
|
20
|
+
otherwise they name this install's Node and entry by absolute path, so a
|
|
21
|
+
Claude Code started from an IDE or with a shorter PATH still runs it.
|
|
22
|
+
Checks the hook runs for the agent follow the same rule.
|
|
23
|
+
- `init` keeps a typecheck that runs TypeScript 7's `tsc` (or `tsgo` from
|
|
24
|
+
`@typescript/native-preview`) recording-only and says why. The pnpm and
|
|
25
|
+
Yarn TypeScript recipes refuse TypeScript 7 with a clear reason instead
|
|
26
|
+
of an error from inside the compiler API.
|
|
27
|
+
- The README is rewritten around a 5-minute quickstart, what REDUE covers
|
|
28
|
+
today (census qualifiers) and what it won't do.
|
|
29
|
+
- Known limits, found by the stranger test: a Vitest run writes under
|
|
30
|
+
`node_modules/.vite`, which makes a direct TypeScript typecheck STALE until
|
|
31
|
+
it runs again; an installed file with a second hard link (esbuild's install
|
|
32
|
+
makes one) keeps the test check UNVERIFIED.
|
|
33
|
+
|
|
34
|
+
- Contributors: the census can run every recipe (`--all-recipes`) and
|
|
35
|
+
counts direct TypeScript checks; its unrecorded-program check no longer
|
|
36
|
+
flags Yarn's temporary `node` wrapper; `eval/stranger.mjs` runs fresh
|
|
37
|
+
Claude Code sessions that set REDUE up from the README alone.
|
|
38
|
+
|
|
3
39
|
## 0.1.0-alpha.22 — 2026-10-25
|
|
4
40
|
|
|
5
41
|
A correctness fix for recorded runs. Earlier alphas could report CURRENT for
|
package/README.md
CHANGED
|
@@ -1,229 +1,146 @@
|
|
|
1
1
|
# REDUE
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Npm commands still execute and record historical outcomes, but remain UNVERIFIED.
|
|
5
|
-
[Why this changed and how to upgrade without deleting receipts](docs/user/npm-launcher-notice.md).
|
|
6
|
-
Alpha.2 users should also review the [receipt correction](docs/user/receipt-selection-notice.md).
|
|
3
|
+
**Know which checks still hold. Redo what's due.**
|
|
7
4
|
|
|
8
|
-
|
|
5
|
+
You or your coding agent ran the typecheck, the linter or the tests. Then the
|
|
6
|
+
code changed, or a new agent session started with no memory of it. REDUE
|
|
7
|
+
records each check's result with the exact inputs it ran on, and tells you and
|
|
8
|
+
your agent whether that result still applies to the code you have now, so a
|
|
9
|
+
check that still holds is not run again and one that is stale is.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
conversation history. Which results can you still use?
|
|
11
|
+
## Quickstart (about 5 minutes)
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
For a qualified direct-compiler or supported Yarn/pnpm check (ordinary npm scripts are recording-only):
|
|
18
|
-
|
|
19
|
-
```text
|
|
20
|
-
$ redue status --sync
|
|
21
|
-
✓ typecheck CURRENT / PASS
|
|
22
|
-
? test UNVERIFIED / PASS
|
|
23
|
-
Recording-only: test runtime, transforms and cache inputs need review
|
|
24
|
-
|
|
25
|
-
# Later, after a relevant edit:
|
|
26
|
-
$ redue explain typecheck
|
|
27
|
-
typecheck: STALE / PASS
|
|
28
|
-
src/main.ts changed
|
|
29
|
-
Relevant inputs changed:
|
|
30
|
-
src/main.ts
|
|
31
|
-
Due again when verification is needed: redue run "typecheck"
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
Agent A verifies and exits. Agent B starts fresh. The receipt is still there;
|
|
35
|
-
REDUE checks whether it still applies. Unrelated documentation edits can preserve
|
|
36
|
-
it; relevant source edits make it stale. [Run the reproducible demo](docs/contributor/demo.md).
|
|
37
|
-
|
|
38
|
-
## Install the alpha
|
|
39
|
-
|
|
40
|
-
The opt-in pilot uses **0.1.0-alpha.18**. Follow the
|
|
41
|
-
[fixed-version pilot guide](docs/user/pilot.md) for supported projects, setup,
|
|
42
|
-
updates and voluntary feedback. No external usage results are claimed yet.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
REDUE is published on npm as `@redue/cli`. The installed command is `redue`.
|
|
13
|
+
You need macOS or Linux, Node 22 or 24, Git, Claude Code, and a JavaScript or
|
|
14
|
+
TypeScript project with its dependencies installed (`npm install`,
|
|
15
|
+
`pnpm install` or `yarn`). On Linux, also Python 3.
|
|
46
16
|
|
|
47
17
|
```sh
|
|
48
18
|
npm install --global @redue/cli@alpha
|
|
19
|
+
cd your-project
|
|
20
|
+
redue setup claude
|
|
49
21
|
```
|
|
50
22
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Use a Node installation you own; REDUE does not need administrator privileges.
|
|
55
|
-
If a global prefix is not writable, use a user-owned `--prefix` or run the
|
|
56
|
-
installed CLI with Node. [Installation and removal](docs/user/getting-started.md)
|
|
57
|
-
includes PowerShell and isolated-prefix instructions.
|
|
58
|
-
|
|
59
|
-
Inside an existing, trusted Git project with dependencies already installed:
|
|
60
|
-
|
|
61
|
-
```sh
|
|
62
|
-
cd my-project
|
|
63
|
-
redue init
|
|
64
|
-
redue start
|
|
65
|
-
redue run typecheck # use a check name printed by init
|
|
66
|
-
redue status
|
|
67
|
-
```
|
|
23
|
+
`setup` shows what it will do and asks once (`--yes` skips the question). It
|
|
24
|
+
stops before writing anything if the dependencies are not installed. It:
|
|
68
25
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
26
|
+
- finds your typecheck, lint and test scripts and writes `redue.config.json`
|
|
27
|
+
(it never changes your scripts, source or dependencies). Checks REDUE can
|
|
28
|
+
only record, not reuse, are listed as recording-only;
|
|
29
|
+
- connects Claude Code: a short section in `CLAUDE.md`, a skill, and two hooks
|
|
30
|
+
in `.claude/settings.local.json`;
|
|
31
|
+
- starts REDUE's file observer, waits while it indexes the project (a minute
|
|
32
|
+
or more in a large one), then runs each check that can qualify once, so
|
|
33
|
+
there is a first result to reuse;
|
|
34
|
+
- prints every file it writes. Running it again changes nothing.
|
|
73
35
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
36
|
+
Then start a new Claude Code session in the project and work as usual. When
|
|
37
|
+
the agent is about to rerun a check whose last pass still applies, the hook
|
|
38
|
+
skips it and tells the agent why. After an edit, the hook runs the check
|
|
39
|
+
through REDUE so the new result is recorded.
|
|
78
40
|
|
|
79
|
-
|
|
80
|
-
for npm typechecks, the pnpm and Yarn TypeScript recipes, and the
|
|
81
|
-
[direct test recipe](docs/user/tests-direct.md). A root typecheck or lint
|
|
82
|
-
script none of them takes can use the [general recipe](docs/user/node-script.md),
|
|
83
|
-
which runs the script through the package manager and records every Node
|
|
84
|
-
process; a script that starts another program (git, esbuild, turbo) stays
|
|
85
|
-
UNVERIFIED and the reason names it. Test scripts use the direct test recipe
|
|
86
|
-
(Vitest, Jest); other test scripts stay recording-only.
|
|
41
|
+
To look yourself:
|
|
87
42
|
|
|
88
|
-
|
|
43
|
+
```text
|
|
44
|
+
$ redue status --sync
|
|
45
|
+
✓ typecheck CURRENT / PASS
|
|
46
|
+
✓ lint CURRENT / PASS
|
|
89
47
|
|
|
90
|
-
|
|
91
|
-
redue
|
|
92
|
-
|
|
93
|
-
|
|
48
|
+
# after editing src/main.ts
|
|
49
|
+
$ redue explain typecheck
|
|
50
|
+
typecheck: STALE / PASS
|
|
51
|
+
src/main.ts changed
|
|
52
|
+
Due again when verification is needed: redue run "typecheck"
|
|
94
53
|
```
|
|
95
54
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
- **
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
- **Limits:** one run of three projects shows direction, not a rate. The
|
|
160
|
-
source-change task skipped nothing; its time difference is session variance.
|
|
161
|
-
In two mixed-task sessions the agent left out its final test run on its own
|
|
162
|
-
after a README-only edit, without asking REDUE, and said so; the eval report
|
|
163
|
-
lists such sessions. Since alpha.15 the agent instructions ask agents to run
|
|
164
|
-
the check and leave skipping to REDUE; in a 12-session rerun of that task,
|
|
165
|
-
no agent left it out. Cost was the same ($0.54 without, $0.56 with).
|
|
55
|
+
`--sync` checks the files and your environment before it answers; this is
|
|
56
|
+
what the agent asks. Plain `redue status` is a fast cached answer and can say
|
|
57
|
+
UNVERIFIED ("cannot confirm your execution context") where `--sync` would say
|
|
58
|
+
CURRENT.
|
|
59
|
+
|
|
60
|
+
To undo everything setup did: `redue remove`.
|
|
61
|
+
|
|
62
|
+
## What it covers today
|
|
63
|
+
|
|
64
|
+
In a census of 120 active public TypeScript repositories (October 2026, REDUE
|
|
65
|
+
at commit c9034e5, just after 0.1.0-alpha.22), 89 installed cleanly. A check
|
|
66
|
+
counts when two recorded runs on the same code both came out CURRENT on the
|
|
67
|
+
same inputs. 31 repositories had at least one such check:
|
|
68
|
+
|
|
69
|
+
| Check | Repositories | How |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| Typecheck | 22 | 16 by running the project's script with every Node process recorded ([general recipe](docs/user/node-script.md)); 6 by running the local `tsc` directly ([direct recipe](docs/user/direct-typescript.md)) |
|
|
72
|
+
| Lint | 18 | general recipe |
|
|
73
|
+
| Tests | 2 | Vitest run directly ([direct test recipe](docs/user/tests-direct.md)) |
|
|
74
|
+
|
|
75
|
+
Everything else stays UNVERIFIED, with a reason you can read in
|
|
76
|
+
`redue explain`. The usual ones: a script starts a program REDUE cannot see
|
|
77
|
+
into (esbuild, turbo, nx, oxlint's tsgolint, TypeScript 7's native compiler),
|
|
78
|
+
the project uses Bun, or a test suite writes into its own inputs. Two you may
|
|
79
|
+
meet right after setup: a Vitest run writes its results under
|
|
80
|
+
`node_modules/.vite`, which the direct TypeScript recipe counts as an
|
|
81
|
+
installed input, so the typecheck turns STALE until it runs again; and an
|
|
82
|
+
installed file with a second hard link (esbuild's install makes one) keeps
|
|
83
|
+
the test check UNVERIFIED.
|
|
84
|
+
[Supported projects](docs/user/supported-projects.md) has the details.
|
|
85
|
+
|
|
86
|
+
## Measured with Claude Code
|
|
87
|
+
|
|
88
|
+
Headless Claude Code sessions (Sonnet 5.5, CI Linux runner) on the same tasks
|
|
89
|
+
with and without REDUE, three sessions per task. Session time, medians per
|
|
90
|
+
task summed over the projects:
|
|
91
|
+
|
|
92
|
+
- **Typecheck** (twilio-node, semiotic, Ultra-Card, SwitchHosts; four tasks;
|
|
93
|
+
96 sessions; 0.1.0-alpha.12 code): 464 s without, 330 s with. REDUE skipped
|
|
94
|
+
33 typechecks. Each was checked afterwards with plain `tsc` on the exact tree
|
|
95
|
+
it was skipped on, and all 33 agreed.
|
|
96
|
+
- **Unit tests** (cheerio with Vitest 4, SwitchHosts with Vitest 5, twilio-node
|
|
97
|
+
with Jest 29; four tasks; 72 sessions; 0.1.0-alpha.14 code): 515 s without,
|
|
98
|
+
295 s with. REDUE skipped 21 test runs. Each was rerun plainly three times on
|
|
99
|
+
the exact tree, and all passed every time.
|
|
100
|
+
|
|
101
|
+
API cost was the same in both arms. The savings come only when an earlier
|
|
102
|
+
pass still applies: a task that changes source code saves nothing. A handful
|
|
103
|
+
of projects and three runs per task show direction, not a general rate. The
|
|
104
|
+
harness, tasks and raw results are in [`eval/`](eval/README.md).
|
|
105
|
+
|
|
106
|
+
## What REDUE won't do
|
|
107
|
+
|
|
108
|
+
- **Prove your code is correct.** CURRENT means the inputs of the recorded
|
|
109
|
+
run are unchanged, not that the code works. A failure stays a failure.
|
|
110
|
+
- **Run checks for you.** Nothing runs in the background; checks run when you
|
|
111
|
+
or the agent run them.
|
|
112
|
+
- **Trust what it cannot see.** A program it cannot account for, a file it
|
|
113
|
+
does not observe, or a gap in observation makes the result UNVERIFIED and
|
|
114
|
+
says why. It never turns that into CURRENT by guessing.
|
|
115
|
+
- **Support yet:** TypeScript 7's native compiler, Bun, per-package checks in
|
|
116
|
+
large monorepos, results shared across machines, and the general recipe on
|
|
117
|
+
Windows (Windows has the strict recipes only).
|
|
166
118
|
|
|
167
119
|
## What the states mean
|
|
168
120
|
|
|
169
|
-
| State | Meaning |
|
|
121
|
+
| State | Meaning | Next step |
|
|
170
122
|
| --- | --- | --- |
|
|
171
|
-
| CURRENT | The recorded result applies to the
|
|
172
|
-
| STALE |
|
|
173
|
-
| UNVERIFIED |
|
|
174
|
-
| FAILED | The
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
never becomes a successful check. CURRENT does not mean every product behavior
|
|
178
|
-
has been verified.
|
|
179
|
-
|
|
180
|
-
## Supported alpha path
|
|
181
|
-
|
|
182
|
-
- macOS, Linux, and Windows on supported local filesystems; Node 22+.
|
|
183
|
-
- Linux also requires Python 3 for observation. No WSL or Git Bash needed on Windows.
|
|
184
|
-
- npm, pinned Yarn `node-modules`, and pinned pnpm 12 isolated/hoisted layouts.
|
|
185
|
-
- Automatic qualification for supported Yarn/pnpm TypeScript invocations; ordinary npm
|
|
186
|
-
launcher checks remain recording-only. Explicitly select the
|
|
187
|
-
[direct local compiler recipe](docs/user/direct-typescript.md) for the reviewed
|
|
188
|
-
npm/TypeScript boundary. Workspace
|
|
189
|
-
boundaries follow the files actually consumed, including built declarations.
|
|
190
|
-
- Opt-in [direct test recipe](docs/user/tests-direct.md) for Vitest 4/5 and
|
|
191
|
-
Jest 29 in single-package npm projects on macOS and Linux.
|
|
192
|
-
- Other test/lint/build and arbitrary commands can record results while
|
|
193
|
-
their applicability remains unqualified.
|
|
194
|
-
|
|
195
|
-
The [supported-project boundary](docs/user/supported-projects.md) is deliberately
|
|
196
|
-
narrow. A script named `test` is not automatically a complete test contract.
|
|
197
|
-
|
|
198
|
-
## Alpha limitations
|
|
199
|
-
|
|
200
|
-
- REDUE records evidence; it does not prove correctness.
|
|
201
|
-
- Direct commands outside `redue run` do not create receipts.
|
|
202
|
-
- Some tests, lints, and builds remain UNVERIFIED/PASS because their input contract is incomplete.
|
|
203
|
-
- Synchronized applicability has nontrivial cost and can lose to an inexpensive rerun.
|
|
204
|
-
- Unsupported/exotic install layouts and filesystems remain conservative; they do not become green through guesses.
|
|
205
|
-
- No cloud/team synchronization, IDE extension, or automatic background check execution.
|
|
206
|
-
- Alpha configuration and APIs may evolve. Neither cached nor synchronized status is an atomic snapshot against concurrent edits.
|
|
207
|
-
|
|
208
|
-
## Humans, shells, and agents
|
|
123
|
+
| CURRENT | The recorded result applies to the code as it is now. | Reuse a pass; no need to rerun. |
|
|
124
|
+
| STALE | An input changed since the recorded result. | Rerun when you need the answer. |
|
|
125
|
+
| UNVERIFIED | REDUE cannot tell: no result yet, an input it cannot see, or observation not ready. | Read the reason; rerun if it matters. |
|
|
126
|
+
| FAILED | The last run failed: the check ran and reported a failure, or could not run. | Fix and rerun. |
|
|
127
|
+
|
|
128
|
+
## Without Claude Code, or step by step
|
|
209
129
|
|
|
210
130
|
```sh
|
|
211
|
-
redue
|
|
212
|
-
redue
|
|
213
|
-
redue
|
|
214
|
-
redue status --
|
|
215
|
-
redue stop # retains historical receipts
|
|
216
|
-
redue remove-state # removes only this config's owned local state
|
|
131
|
+
redue init # write redue.config.json from your scripts
|
|
132
|
+
redue start # start the file observer
|
|
133
|
+
redue run typecheck # run a check through REDUE and record the result
|
|
134
|
+
redue status --sync # what still holds
|
|
217
135
|
```
|
|
218
136
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
synchronized work in a prompt.
|
|
137
|
+
Codex is supported too (`redue agent setup codex --apply`); see
|
|
138
|
+
[agents](docs/user/agents.md). Agents should read
|
|
139
|
+
[the JSON interface](docs/user/json.md), not the terminal text.
|
|
223
140
|
|
|
224
|
-
[
|
|
225
|
-
[
|
|
141
|
+
[Getting started](docs/user/getting-started.md) · [User docs](docs/README.md) ·
|
|
142
|
+
[Contributing](CONTRIBUTING.md) · [Architecture](docs/contributor/architecture.md)
|
|
226
143
|
|
|
227
144
|
## License
|
|
228
145
|
|
|
229
|
-
REDUE is licensed under [Apache-2.0](LICENSE). Dependencies
|
|
146
|
+
REDUE is licensed under [Apache-2.0](LICENSE). Dependencies keep their own licenses.
|
package/bin/redue.mjs
CHANGED
|
@@ -28,6 +28,9 @@ const help=`REDUE ${version}
|
|
|
28
28
|
Usage: redue [--config FILE] [--state-dir DIR] COMMAND [OPTIONS]
|
|
29
29
|
|
|
30
30
|
Commands:
|
|
31
|
+
setup claude [--yes] Set up REDUE and Claude Code in this project in one step:
|
|
32
|
+
init, agent setup claude, start, one recorded run per check
|
|
33
|
+
remove [--yes] Undo setup: integration, observer state, and the config setup created
|
|
31
34
|
init [--dry-run] [--workspace NAME] [--check KIND] [--recipe typescript-direct|tests-direct|node-script]
|
|
32
35
|
Discover existing scripts; optionally select one workspace/check
|
|
33
36
|
agent setup codex|claude [--dry-run|--apply] [--json]
|
|
@@ -58,7 +61,18 @@ if(args.length===3&&args[0]==='agent'&&args[1]==='hook'&&args[2]==='claude'){
|
|
|
58
61
|
await runClaudeHook(fileURLToPath(import.meta.url));process.exit(0);
|
|
59
62
|
}
|
|
60
63
|
if(args.length===1&&args[0]==='--version'){console.log(version);process.exit(0);}
|
|
64
|
+
// Internal: the entry this command runs, so setup and the hook can tell
|
|
65
|
+
// whether the `redue` on PATH (a link, a package manager's shim, a wrapper)
|
|
66
|
+
// is this install.
|
|
67
|
+
if(args.length===1&&args[0]==='--entry'){console.log(fs.realpathSync(fileURLToPath(import.meta.url)));process.exit(0);}
|
|
61
68
|
if(args.includes('--help')||args.includes('-h')||!args.length){console.log(help);await exit(0);}
|
|
69
|
+
if(args[0]==='setup'||args[0]==='remove'){
|
|
70
|
+
// Loaded on demand; setup works before a config exists.
|
|
71
|
+
const {setupCommand}=await import('../src/setup.mjs');
|
|
72
|
+
try{await setupCommand(args,{entry:fileURLToPath(import.meta.url)});}
|
|
73
|
+
catch(e){error(e.message);}
|
|
74
|
+
await exit(0);
|
|
75
|
+
}
|
|
62
76
|
let configFile=null,stateOverride=null;
|
|
63
77
|
for(let i=0;i<args.length;){
|
|
64
78
|
if(args[i]==='--config'||args[i]==='--state-dir'){
|
|
@@ -124,7 +138,7 @@ if(command==='init'){
|
|
|
124
138
|
checks:discovery.checks.map(({config:check,level,reason})=>({name:check.name,
|
|
125
139
|
script:check.script||null,command:check.command||null,
|
|
126
140
|
workspace:check.workspace||null,level,reason})),config:configFile,
|
|
127
|
-
written:willWrite,existing:configured};
|
|
141
|
+
written:willWrite,existing:configured,writable:!configured&&!unsupported&&discovery.checks.length>0};
|
|
128
142
|
if(!json) {
|
|
129
143
|
console.log(`Node ${summary.node}; ${summary.package_manager||'unknown package manager'}`+
|
|
130
144
|
`${summary.version?' '+summary.version:''}; ${summary.installation_layout} installation.`);
|
package/docs/user/agents.md
CHANGED
|
@@ -74,10 +74,18 @@ observer when a session starts (below); otherwise use `redue start`.
|
|
|
74
74
|
|
|
75
75
|
## Claude Code typecheck hook
|
|
76
76
|
|
|
77
|
-
`redue agent setup claude --apply`
|
|
78
|
-
SessionStart hook to `.claude/settings.local.json`.
|
|
79
|
-
`redue agent hook claude`, which needs a local REDUE install, so
|
|
80
|
-
per-developer; keep that file out of commits.
|
|
77
|
+
`redue agent setup claude --apply` (and `redue setup claude`) also adds a
|
|
78
|
+
PreToolUse hook and a SessionStart hook to `.claude/settings.local.json`.
|
|
79
|
+
Both run `redue agent hook claude`, which needs a local REDUE install, so
|
|
80
|
+
they are per-developer; keep that file out of commits. Setup names REDUE
|
|
81
|
+
plainly only when the `redue` that both your shell and a login shell find on
|
|
82
|
+
PATH is the install running setup. Otherwise the hooks name this install's
|
|
83
|
+
Node and entry by absolute path, so a Claude Code started from an IDE, a
|
|
84
|
+
desktop app or another shell still runs the right REDUE; run setup again
|
|
85
|
+
after moving or upgrading Node. `redue agent doctor` reports a hook whose
|
|
86
|
+
paths no longer exist. When the hook runs a check through REDUE for the
|
|
87
|
+
agent, it likewise writes plain `redue run` only if its own PATH finds this
|
|
88
|
+
install, else the absolute form. Other settings in the file are kept,
|
|
81
89
|
but the file is rewritten as 2-space JSON. `.claude/settings.json` is not touched.
|
|
82
90
|
`redue agent remove claude --apply` removes only the entries REDUE added.
|
|
83
91
|
|
|
@@ -7,7 +7,9 @@ not mean npm or npm lifecycle scripts completed.
|
|
|
7
7
|
For a supported single-package npm project with a typecheck script of `tsc`
|
|
8
8
|
runs (see [script forms](#script-forms)) or no typecheck script and a root
|
|
9
9
|
`tsconfig.json`, installed dependencies, Node 22/24 and TypeScript 5.x or 6.0
|
|
10
|
-
(see [compiler identity](#compiler-identity))
|
|
10
|
+
(see [compiler identity](#compiler-identity)). TypeScript 7 is a native
|
|
11
|
+
compiler this recipe cannot run: `init` refuses it
|
|
12
|
+
(`direct-typescript-version-unsupported`):
|
|
11
13
|
|
|
12
14
|
```sh
|
|
13
15
|
redue init --recipe typescript-direct --check typecheck --dry-run
|
|
@@ -67,7 +67,26 @@ the package alone does not update project instructions.
|
|
|
67
67
|
|
|
68
68
|
## First project
|
|
69
69
|
|
|
70
|
-
|
|
70
|
+
With Claude Code, one command does the steps below and connects the agent:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
redue setup claude # asks once; --yes for no prompts
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
It runs `init` when the project has no `redue.config.json`, `agent setup
|
|
77
|
+
claude`, and `start`, then records each check that can qualify once, unless
|
|
78
|
+
it is already CURRENT. It prints every file it writes, and running it again
|
|
79
|
+
changes nothing that is already set up. When `.claude/settings.local.json`
|
|
80
|
+
is not ignored by Git, it adds it to `.git/info/exclude`; it refuses when
|
|
81
|
+
that file is committed. What setup created is noted in the clone's Git
|
|
82
|
+
directory (`.git/redue-setup-<id>.json`, never committed). `redue remove`
|
|
83
|
+
undoes exactly that: the integration (or only this machine's hooks, when the
|
|
84
|
+
integration was already in the project), the observer and its state
|
|
85
|
+
(receipts included), the local ignore line, and the config if setup wrote it
|
|
86
|
+
and it has not been edited since. A config or observer state that existed
|
|
87
|
+
before setup stays. `remove` asks before it deletes; Enter means no.
|
|
88
|
+
|
|
89
|
+
Step by step, from the project root:
|
|
71
90
|
|
|
72
91
|
```sh
|
|
73
92
|
redue init --dry-run
|
package/docs/user/node-script.md
CHANGED
|
@@ -113,7 +113,10 @@ one REDUE can account for:
|
|
|
113
113
|
|
|
114
114
|
A process that replaces itself with another program through
|
|
115
115
|
`process.execve` (TypeScript 7's `tsc` does this to start its native
|
|
116
|
-
compiler) is judged the same way as a program it starts.
|
|
116
|
+
compiler) is judged the same way as a program it starts. `init` reads
|
|
117
|
+
TypeScript 7's launchers (`tsc` from `typescript@7`, `tsgo` from
|
|
118
|
+
`@typescript/native-preview`) as that native compiler and keeps such a script
|
|
119
|
+
recording-only.
|
|
117
120
|
|
|
118
121
|
Anything else makes the result UNVERIFIED and names the program: `git`,
|
|
119
122
|
`sleep`, `find`, and native programs from installed packages such as esbuild,
|
|
@@ -83,7 +83,11 @@ entries stay inputs: an import can still reach an excluded file); a file the
|
|
|
83
83
|
program reads outside them keeps the check recording-only. This avoids pnpm's task-run bookkeeping
|
|
84
84
|
writes during `pnpm run`; it does not alter the project's script. Test, lint,
|
|
85
85
|
build, and ambiguous TypeScript scripts still execute through `pnpm run`; root
|
|
86
|
-
typecheck and lint ones can use the [general recipe](node-script.md). The
|
|
86
|
+
typecheck and lint ones can use the [general recipe](node-script.md). The pnpm
|
|
87
|
+
and Yarn TypeScript recipes read TypeScript's compiler API, which TypeScript 7
|
|
88
|
+
does not ship: with TypeScript 7 installed they refuse and say so. The
|
|
89
|
+
general recipe runs such a script, but TypeScript 7's native compiler is a
|
|
90
|
+
program REDUE does not record, so the result is UNVERIFIED. The generated `@typescript-bin:WORKSPACE` token resolves
|
|
87
91
|
the local compiler when the check runs; it is not an absolute machine path.
|
|
88
92
|
|
|
89
93
|
The pnpm contract indexes consumed installed package contents inside the
|
package/package.json
CHANGED
|
@@ -11,7 +11,7 @@ import {readProject,projectState,canonicalConfig} from './project-location.mjs';
|
|
|
11
11
|
import {readCachedStatus} from './cached-state.mjs';
|
|
12
12
|
import {samePath,withinPath} from './path-identity.mjs';
|
|
13
13
|
import {processAlive} from './process-liveness.mjs';
|
|
14
|
-
import {fanoutRules,hookCommand,hookForms,redueFor} from './claude-hook.mjs';
|
|
14
|
+
import {fanoutRules,hookCommand,hookForms,redueFor,runsEntry,shellQuote as quote} from './claude-hook.mjs';
|
|
15
15
|
|
|
16
16
|
const policy=fs.readFileSync(new URL('./agent-policy.md',import.meta.url),'utf8');
|
|
17
17
|
const hosts={codex:{instruction:'AGENTS.md',skill:'.agents/skills/redue-verification/SKILL.md'},
|
|
@@ -25,17 +25,53 @@ const sameJson=(a,b)=>JSON.stringify(a)===JSON.stringify(b);
|
|
|
25
25
|
// The hook waits for status up to 10 s; 15 s leaves room for starting Node.
|
|
26
26
|
// Fan-out commands (turbo, nx, `pnpm -r`) and root scripts that run one get
|
|
27
27
|
// prefix rules; the hook decides from the full command.
|
|
28
|
-
function hookFor(config,relative,scripts){
|
|
28
|
+
function hookFor(config,relative,scripts,command=hookCommand){
|
|
29
29
|
const rules=[...new Set([...hookForms(config,relative,scripts).flatMap(row=>[...row.forms,...row.local]),
|
|
30
30
|
...fanoutRules(config,scripts)])].map(form=>`Bash(${form}*)`);
|
|
31
|
-
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command
|
|
31
|
+
return rules.length?{matcher:'Bash',hooks:rules.map(rule=>({type:'command',if:rule,command,timeout:15}))}:null;
|
|
32
32
|
}
|
|
33
33
|
// The same command on SessionStart starts the observer; it needs no check to match.
|
|
34
|
-
const
|
|
34
|
+
const sessionHookFor=(command=hookCommand)=>({hooks:[{type:'command',command,timeout:10}]});
|
|
35
35
|
const events=['PreToolUse','SessionStart'];
|
|
36
|
+
// The absolute form: Node and REDUE's entry (redue.mjs), each quoted as quote()
|
|
37
|
+
// writes it. [node, entry] or null.
|
|
38
|
+
function absoluteHook(command){
|
|
39
|
+
const word="'((?:[^']|'\\\\'')*)'";
|
|
40
|
+
const match=new RegExp(`^${word} ${word} agent hook claude$`).exec(command??'');
|
|
41
|
+
if(!match)return null;
|
|
42
|
+
const [node,entry]=[match[1],match[2]].map(value=>value.replaceAll("'\\''","'"));
|
|
43
|
+
// REDUE's entry file, or the bin link npm installs for it.
|
|
44
|
+
return /^redue(\.mjs)?$/.test(path.basename(entry))?[node,entry]:null;
|
|
45
|
+
}
|
|
46
|
+
export const isHookCommand=command=>command===hookCommand||Boolean(absoluteHook(command));
|
|
47
|
+
// The command Claude Code runs for the hook. Plain `redue` when this install
|
|
48
|
+
// is the one both this shell and a login shell find on PATH. Otherwise this
|
|
49
|
+
// install's Node and entry by absolute path, so a Claude Code started from
|
|
50
|
+
// another shell, an IDE or with a shorter PATH still runs it, and an older
|
|
51
|
+
// install on PATH does not answer for this one. The settings file is
|
|
52
|
+
// per-developer, so a machine path is fine there.
|
|
53
|
+
export function hookInvocation(entry,{env=process.env,platform=process.platform}={}){
|
|
54
|
+
if(platform==='win32')return {command:hookCommand,reason:'Windows: plain redue on PATH'};
|
|
55
|
+
let target;try{target=fs.realpathSync(entry);}catch{target=entry;}
|
|
56
|
+
// Paths that survive upgrades where they can: the entry as invoked (a
|
|
57
|
+
// global bin link, not the versioned file behind it) and the node on PATH
|
|
58
|
+
// (Homebrew's link, not its versioned cellar).
|
|
59
|
+
const real=file=>{try{return fs.realpathSync(file);}catch{return null;}};
|
|
60
|
+
const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&real(process.argv[1])===target?process.argv[1]:target;
|
|
61
|
+
const execReal=real(process.execPath);
|
|
62
|
+
const node=(env.PATH||'').split(path.delimiter).map(dir=>path.join(dir||'.','node')).find(file=>real(file)===execReal)??process.execPath;
|
|
63
|
+
const same=file=>runsEntry(file,target,env);
|
|
64
|
+
const here=findExecutable('redue',{env});
|
|
65
|
+
const login=spawnSync(env.SHELL||'/bin/sh',['-lc','command -v redue'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
|
|
66
|
+
const loginPath=login.status===0?login.stdout.trim().split('\n').pop():null;
|
|
67
|
+
if(same(here)&&same(loginPath))return {command:hookCommand,reason:`redue on PATH is this install (${here})`};
|
|
68
|
+
return {command:`${quote(node)} ${quote(invoked)} agent hook claude`,
|
|
69
|
+
reason:!here&&!loginPath?'redue is not on PATH; the hook names this install by its absolute path':
|
|
70
|
+
`redue on PATH is ${here||loginPath||'missing'} in ${same(here)?'a login shell':'this shell'}, not this install; the hook names this install by its absolute path`};
|
|
71
|
+
}
|
|
36
72
|
// REDUE's entry is recognized by its command, not by a committed record: the
|
|
37
73
|
// local file differs between checkouts, and users may edit the entry.
|
|
38
|
-
const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>h?.command
|
|
74
|
+
const ownedHook=entry=>Array.isArray(entry?.hooks)&&entry.hooks.length>0&&entry.hooks.every(h=>isHookCommand(h?.command));
|
|
39
75
|
function parseSettings(text){
|
|
40
76
|
if(text===null)return {};
|
|
41
77
|
let value;try{value=JSON.parse(text);}catch{throw Error(`${settingsPath} is not valid JSON; fix it before agent setup or removal`);}
|
|
@@ -65,6 +101,45 @@ function hookInstalled(root,event='PreToolUse'){
|
|
|
65
101
|
try{return (parseSettings(read(root,settingsPath)).hooks?.[event]||[]).some(ownedHook);}
|
|
66
102
|
catch{return false;}
|
|
67
103
|
}
|
|
104
|
+
// What removing a host's integration changes, from its setup record: the
|
|
105
|
+
// managed block, the skill, the hooks and the record.
|
|
106
|
+
function removalChanges(root,host,record){
|
|
107
|
+
const h=hosts[host],before=read(root,h.instruction),after=before===null?null:before.replace(record.block,'');
|
|
108
|
+
const changes=[{path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
|
|
109
|
+
{path:h.skill,before:read(root,h.skill),after:null}];
|
|
110
|
+
if(host==='claude'){const settings=read(root,settingsPath);
|
|
111
|
+
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
|
|
112
|
+
changes.push({path:marker(host),before:read(root,marker(host)),after:null});
|
|
113
|
+
return changes;
|
|
114
|
+
}
|
|
115
|
+
// Removes a host's integration without needing the project config (redue
|
|
116
|
+
// remove after the config is gone): the changed paths, each with its operation.
|
|
117
|
+
export function removeIntegration(root,host='claude'){
|
|
118
|
+
const record=saved(root,host);if(!record)return [];
|
|
119
|
+
const health=inspect(root,host,record);if(health.integrity==='conflict')throw Error(health.reason);
|
|
120
|
+
const changes=removalChanges(root,host,record).filter(c=>c.before!==c.after);
|
|
121
|
+
apply(root,changes);
|
|
122
|
+
return changes.map(c=>`${c.after===null?'remove':'update'} ${c.path}`);
|
|
123
|
+
}
|
|
124
|
+
// Whether REDUE's Claude Code hooks are in the local settings.
|
|
125
|
+
export const claudeHooksInstalled=root=>hookInstalled(root,'SessionStart')||hookInstalled(root);
|
|
126
|
+
// Removes REDUE's Claude Code hooks from the local settings and keeps the
|
|
127
|
+
// rest; the file's path when it changed, else null.
|
|
128
|
+
export function removeClaudeHooks(root){
|
|
129
|
+
const before=read(root,settingsPath);if(before===null)return null;
|
|
130
|
+
const after=settingsWith(before,null);if(after===before)return null;
|
|
131
|
+
apply(root,[{path:settingsPath,before,after}]);return settingsPath;
|
|
132
|
+
}
|
|
133
|
+
// Whether the installed hook's command can start: an absolute one by its
|
|
134
|
+
// files, plain redue by PATH.
|
|
135
|
+
function hookRunnable(root){
|
|
136
|
+
let command=null;
|
|
137
|
+
try{command=(parseSettings(read(root,settingsPath)).hooks?.SessionStart||[]).find(ownedHook)?.hooks[0]?.command??null;}catch{}
|
|
138
|
+
const absolute=absoluteHook(command);
|
|
139
|
+
if(absolute)return fs.existsSync(absolute[0])&&fs.existsSync(absolute[1])?'installed (absolute path)':
|
|
140
|
+
'installed, but its Node or REDUE path no longer exists; run redue agent setup claude again';
|
|
141
|
+
return findExecutable('redue')?'installed':'installed, but redue is not on PATH, so it never runs';
|
|
142
|
+
}
|
|
68
143
|
function safeFile(root,relative){
|
|
69
144
|
if(path.isAbsolute(relative)||relative.split(/[\\/]/).some(p=>p==='..'||p===''))throw Error('unsafe integration path');
|
|
70
145
|
const file=path.resolve(root,relative);
|
|
@@ -273,7 +348,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
273
348
|
const {root,relative,config}=project(configFile),h=hosts[host],record=saved(root,host),health=inspect(root,host,record);
|
|
274
349
|
if(health.integrity==='conflict')throw Error(health.reason);
|
|
275
350
|
const changes=[],notes=action==='setup'?warnings(root,host,cwd):[];
|
|
276
|
-
let hook=null,block=null;
|
|
351
|
+
let hook=null,sessionHook=null,block=null;
|
|
277
352
|
if(action==='setup'){
|
|
278
353
|
const before=read(root,h.instruction),skill=skillFor(relative,host,config);
|
|
279
354
|
block=blockFor(host,blockForms(host,config,relative));
|
|
@@ -281,7 +356,12 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
281
356
|
throw Error('unowned REDUE instruction or skill already exists; inspect it before setup');
|
|
282
357
|
const after=record?before.replace(record.block,block):(before||'')+block;
|
|
283
358
|
let scripts={};try{scripts=JSON.parse(read(root,'package.json')??'{}').scripts||{};}catch{}
|
|
284
|
-
|
|
359
|
+
const invocation=host==='claude'?hookInvocation(entry):null;
|
|
360
|
+
if(invocation)notes.push(`Hook command: ${invocation.command} (${invocation.reason})`);
|
|
361
|
+
if(invocation&&invocation.command!==hookCommand)
|
|
362
|
+
notes.push('The agent instructions say `redue ...`: put this install on PATH (npm install --global @redue/cli@alpha) so the agent\'s shell can run them. The hook runs the checks it rewrites through this install either way.');
|
|
363
|
+
hook=host==='claude'?hookFor(config,relative,scripts,invocation.command):null;
|
|
364
|
+
sessionHook=host==='claude'?sessionHookFor(invocation.command):null;
|
|
285
365
|
const next={schema:1,host,config:relative,instruction:h.instruction,skill:h.skill,
|
|
286
366
|
block,skillHash:hash(skill),instructionCreated:record?.instructionCreated??before===null};
|
|
287
367
|
changes.push({path:h.skill,before:read(root,h.skill),after:skill},
|
|
@@ -291,14 +371,7 @@ export async function agentCommand(args,{configFile,stateOverride,entry,cwd=proc
|
|
|
291
371
|
content:JSON.stringify(hook?next:{SessionStart:sessionHook},null,2)+
|
|
292
372
|
(hook?'':'\nno PreToolUse hook: no typecheck or test check to match')});}
|
|
293
373
|
changes.push({path:marker(host),before:read(root,marker(host)),after:JSON.stringify(next,null,2)+'\n'});
|
|
294
|
-
}else if(record)
|
|
295
|
-
const before=read(root,h.instruction),after=before.replace(record.block,'');
|
|
296
|
-
changes.push({path:h.instruction,before,after:record.instructionCreated&&after===''?null:after},
|
|
297
|
-
{path:h.skill,before:read(root,h.skill),after:null});
|
|
298
|
-
if(host==='claude'){const settings=read(root,settingsPath);
|
|
299
|
-
changes.push({path:settingsPath,before:settings,after:settingsWith(settings,null)});}
|
|
300
|
-
changes.push({path:marker(host),before:read(root,marker(host)),after:null});
|
|
301
|
-
}
|
|
374
|
+
}else if(record)changes.push(...removalChanges(root,host,record));
|
|
302
375
|
const actual=changes.filter(c=>c.before!==c.after);
|
|
303
376
|
const summary={schema:1,action,host,config:configFile,applied:false,
|
|
304
377
|
changes:actual.map(c=>({path:c.path,operation:c.after===null?'remove':c.before===null?'create':'update',
|
|
@@ -355,7 +428,7 @@ export async function doctor({configFile,stateOverride,entry,cwd,host}){
|
|
|
355
428
|
const out=spawnSync(launch.file,launch.args,{...launch.options,encoding:'utf8',timeout:2000,maxBuffer:4096,stdio:['ignore','pipe','pipe']});
|
|
356
429
|
if(out.status===0)version=out.stdout.trim();}
|
|
357
430
|
let integration={installed:false,integrity:'unknown'},hook=name==='claude'?'absent':'none',sessionHook=hook;
|
|
358
|
-
const installed=()=>
|
|
431
|
+
const installed=()=>hookRunnable(p.root);
|
|
359
432
|
if(p)try{const record=saved(p.root,name);integration=inspect(p.root,name,record);
|
|
360
433
|
if(name==='claude'&&hookInstalled(p.root))hook=installed();
|
|
361
434
|
if(name==='claude'&&hookInstalled(p.root,'SessionStart'))sessionHook=installed();
|
package/src/claude-hook.mjs
CHANGED
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
// Never exit 2: Claude Code treats exit 2 as a block.
|
|
7
7
|
import fs from 'node:fs';
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import {spawn} from 'node:child_process';
|
|
9
|
+
import {spawn,spawnSync} from 'node:child_process';
|
|
10
10
|
import {projectState} from './project-location.mjs';
|
|
11
11
|
import {realObservedPath} from './path-identity.mjs';
|
|
12
12
|
import {processAlive} from './process-liveness.mjs';
|
|
13
13
|
import {scriptRunChecks} from './typescript-invocation.mjs';
|
|
14
14
|
import {recipeLabel} from './presentation.mjs';
|
|
15
|
+
import {findExecutable} from './executable-lookup.mjs';
|
|
15
16
|
|
|
16
17
|
export const hookCommand='redue agent hook claude';
|
|
17
18
|
// No shell metacharacters; `@` starts scoped package names.
|
|
@@ -31,6 +32,22 @@ export const defaultConfig='redue.config.json';
|
|
|
31
32
|
// would need shell quoting.
|
|
32
33
|
export const redueFor=(configFile=defaultConfig)=>configFile===defaultConfig?'redue':
|
|
33
34
|
safe.test(configFile)?`redue --config ${configFile}`:null;
|
|
35
|
+
export const shellQuote=value=>`'${String(value).replaceAll("'","'\\''")}'`;
|
|
36
|
+
// How a rewritten command starts REDUE: plain `redue` when the hook's PATH
|
|
37
|
+
// finds this install, else this install by absolute path. An agent shell
|
|
38
|
+
// without redue on PATH cannot run the plain form.
|
|
39
|
+
export function redueInvocation(entry,{env=process.env,platform=process.platform}={}){
|
|
40
|
+
if(platform==='win32'||runsEntry(findExecutable('redue',{env}),entry,env))return 'redue';
|
|
41
|
+
return `${shellQuote(process.execPath)} ${shellQuote(entry)}`;
|
|
42
|
+
}
|
|
43
|
+
// Whether the command at `file` runs this install's entry: the same file, or
|
|
44
|
+
// a shim or wrapper that reports it (redue --entry).
|
|
45
|
+
export function runsEntry(file,entry,env=process.env){
|
|
46
|
+
if(!file)return false;
|
|
47
|
+
try{const target=fs.realpathSync(entry);if(fs.realpathSync(file)===target)return true;
|
|
48
|
+
const out=spawnSync(file,['--entry'],{env,encoding:'utf8',timeout:5000,stdio:['ignore','pipe','ignore']});
|
|
49
|
+
return out.status===0&&fs.realpathSync(out.stdout.trim())===target;}catch{return false;}
|
|
50
|
+
}
|
|
34
51
|
|
|
35
52
|
// Package-manager commands that a script of the same name would not reach.
|
|
36
53
|
const builtins={pnpm:new Set(['add','audit','bin','config','create','dedupe','deploy','dlx','doctor','env','exec','fetch',
|
|
@@ -265,9 +282,9 @@ export function matchCheck(command,config,configFile,dir,scripts){
|
|
|
265
282
|
}
|
|
266
283
|
// The same check (or checks, in order) through REDUE, keeping the output
|
|
267
284
|
// suffix; null if it already is, or if more commands follow it.
|
|
268
|
-
export function recordedCommand(command,name,configFile=defaultConfig){
|
|
285
|
+
export function recordedCommand(command,name,configFile=defaultConfig,invoke='redue'){
|
|
269
286
|
const names=Array.isArray(name)?name:[name];
|
|
270
|
-
const parts=split(command),redue=redueFor(configFile);
|
|
287
|
+
const parts=split(command),redue=redueFor(configFile)?.replace(/^redue/,()=>invoke);
|
|
271
288
|
if(!parts||parts.rest!==null||!names.length||!names.every(value=>safe.test(value))||!redue)return null;
|
|
272
289
|
const run=names.map(value=>`${redue} run ${value}`).join(' && ');
|
|
273
290
|
return parts.base===run?null:names.length>1&&parts.suffix?`(${run})${parts.suffix}`:run+parts.suffix;
|
|
@@ -374,7 +391,7 @@ export function sessionStart(input,{entry}){
|
|
|
374
391
|
}
|
|
375
392
|
|
|
376
393
|
// Returns the hook's stdout object, or null to allow the command.
|
|
377
|
-
export async function claudeHook(input,{entry,timeout}){
|
|
394
|
+
export async function claudeHook(input,{entry,timeout,invoke=null}){
|
|
378
395
|
if(input?.tool_name!=='Bash')return null;
|
|
379
396
|
const command=input.tool_input?.command,root=input.cwd;
|
|
380
397
|
if(typeof command!=='string'||/^\s*REDUE_FORCE=1(?:\s|$)/.test(command))return null;
|
|
@@ -414,7 +431,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
414
431
|
if(reason)return {hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'deny',permissionDecisionReason:reason}};
|
|
415
432
|
const pending=names.filter(name=>!verdict(status,name));
|
|
416
433
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
417
|
-
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath):null;
|
|
434
|
+
names.every(name=>status.checks.some(item=>item?.name===name))?recordedCommand(entered.command,pending,configPath,invoke??redueInvocation(entry)):null;
|
|
418
435
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
|
419
436
|
permissionDecisionReason:`REDUE: ${pending.map(name=>JSON.stringify(name)).join(', ')} of this script's checks ${pending.length===1?'is':'are'} not CURRENT; running ${pending.length===1?'it':'them'} through redue run so the result is recorded.`,
|
|
420
437
|
updatedInput:{...input.tool_input,command:entered.prefix+rewritten}}}:null;
|
|
@@ -429,7 +446,7 @@ export async function claudeHook(input,{entry,timeout}){
|
|
|
429
446
|
// stays too: the rewrite returns "allow", which would also skip the prompt
|
|
430
447
|
// for the rest of the chain.
|
|
431
448
|
const rewritten=status?.schema===1&&status.observation?.healthy===true&&Array.isArray(status.checks)&&
|
|
432
|
-
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath):null;
|
|
449
|
+
status.checks.some(item=>item?.name===name)?recordedCommand(entered.command,name,configPath,invoke??redueInvocation(entry)):null;
|
|
433
450
|
// updatedInput replaces the whole tool input. Deny and ask rules still apply
|
|
434
451
|
// to the rewritten command; "allow" skips only the prompt.
|
|
435
452
|
return rewritten?{hookSpecificOutput:{hookEventName:'PreToolUse',permissionDecision:'allow',
|
package/src/node-script.mjs
CHANGED
|
@@ -236,6 +236,19 @@ export const scriptPath=(dir,callerPath)=>{
|
|
|
236
236
|
// Why a shell command line could start a program REDUE does not record, or
|
|
237
237
|
// null. Names are looked up along `searchPath` from `cwd`. A script the pnpm
|
|
238
238
|
// executable runs is read the same way, since the recorder cannot see it.
|
|
239
|
+
// TypeScript 7's Node launchers (tsc in typescript@7, tsgo in
|
|
240
|
+
// @typescript/native-preview) replace themselves with a native compiler REDUE
|
|
241
|
+
// does not record: the package and version, or null.
|
|
242
|
+
export function nativeTypeScriptLauncher(file){
|
|
243
|
+
let dir;try{dir=path.dirname(fs.realpathSync(file));}catch{return null;}
|
|
244
|
+
for(let n=0;n<3;n++,dir=path.dirname(dir)){
|
|
245
|
+
let pkg;try{pkg=JSON.parse(fs.readFileSync(path.join(dir,'package.json'),'utf8'));}catch{continue;}
|
|
246
|
+
if(pkg.name==='typescript'&&Number(String(pkg.version).split('.')[0])>=7||pkg.name==='@typescript/native-preview')
|
|
247
|
+
return `${pkg.name} ${pkg.version}`;
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
239
252
|
export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
|
|
240
253
|
if(depth>8)return 'the scripts run each other too deeply for REDUE to read';
|
|
241
254
|
const parsed=parseCommandLine(text);
|
|
@@ -270,6 +283,8 @@ export function commandLineIssue(text,{cwd,searchPath,depth=0}={}){
|
|
|
270
283
|
if(kind==='node'){
|
|
271
284
|
const flag=['node','nodejs'].includes(path.basename(file))&&args.find(arg=>nativeReads.test(arg.text));
|
|
272
285
|
if(flag)return `runs node ${flag.text.split('=')[0]}, which reads a file REDUE cannot see`;
|
|
286
|
+
const native=nativeTypeScriptLauncher(file);
|
|
287
|
+
if(native)return `runs ${path.basename(name)} from ${native}, a native compiler REDUE does not record`;
|
|
273
288
|
break;
|
|
274
289
|
}
|
|
275
290
|
if(kind==='pnpm'){const issue=pnpmRunIssue(args.map(arg=>arg.text),{cwd:dir,searchPath,depth});if(issue)return issue;break;}
|
package/src/pnpm-inputs.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// repository-relative input sets and resolution relationships, never pnpm
|
|
3
3
|
// virtual-store names as semantic identities.
|
|
4
4
|
import {probeTimeLeft} from './probe-deadline.mjs';
|
|
5
|
-
import {compilerFiles} from './typescript-list.mjs';
|
|
5
|
+
import {compilerApiIssue,compilerFiles} from './typescript-list.mjs';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import {createRequire} from 'node:module';
|
|
@@ -298,7 +298,9 @@ export function pnpmTypecheckInputs(root,workspace,run,{captureQueries=false}={}
|
|
|
298
298
|
let tsPackage;try{tsPackage=realObservedPath(lookup.resolve('typescript/package.json'));}
|
|
299
299
|
catch{throw Error('installed TypeScript compiler cannot be resolved');}
|
|
300
300
|
if(!within(tsPackage,root))throw Error('TypeScript compiler is outside the observed checkout');
|
|
301
|
-
const tsRoot=path.dirname(tsPackage),
|
|
301
|
+
const tsRoot=path.dirname(tsPackage),apiIssue=compilerApiIssue(tsRoot);
|
|
302
|
+
if(apiIssue)throw Error(apiIssue);
|
|
303
|
+
const ts=lookup('typescript');
|
|
302
304
|
const tsc=path.join(tsRoot,'bin','tsc');
|
|
303
305
|
if(!exists(tsc))throw Error('installed TypeScript executable is missing');
|
|
304
306
|
const configStarted=performance.now();
|
package/src/setup.mjs
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `redue setup claude` and `redue remove`: one command from a project with
|
|
2
|
+
// installed dependencies to a working Claude Code integration, and one to
|
|
3
|
+
// undo it. Setup runs init when the project has no config, agent setup
|
|
4
|
+
// claude, start, and one recorded run of each check init can qualify that is
|
|
5
|
+
// not CURRENT. It prints every file it writes. What it created is kept in a
|
|
6
|
+
// record in the clone's Git directory (never committed); a later setup adds
|
|
7
|
+
// to that account and never forgets it. Remove undoes exactly what the record
|
|
8
|
+
// says setup created: the agent files or just this machine's hooks, the
|
|
9
|
+
// observer state, the local ignore line, and the config if it is unchanged
|
|
10
|
+
// since setup wrote it.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import {createHash} from 'node:crypto';
|
|
14
|
+
import {spawnSync} from 'node:child_process';
|
|
15
|
+
import {createInterface} from 'node:readline/promises';
|
|
16
|
+
import {projectState} from './project-location.mjs';
|
|
17
|
+
import {claudeHooksInstalled,removeClaudeHooks,removeIntegration} from './agent-integration.mjs';
|
|
18
|
+
|
|
19
|
+
const configName='redue.config.json',localSettings='.claude/settings.local.json',agentRecord='.redue/agents/claude.json';
|
|
20
|
+
const hash=text=>createHash('sha256').update(text).digest('hex');
|
|
21
|
+
const readText=file=>{try{return fs.readFileSync(file,'utf8');}catch{return null;}};
|
|
22
|
+
const git=(root,...args)=>spawnSync('git',args,{cwd:root,encoding:'utf8',stdio:['ignore','pipe','ignore']});
|
|
23
|
+
const ignoreLine=`/${localSettings}`;
|
|
24
|
+
|
|
25
|
+
async function confirm(question,yes,{defaultYes}){
|
|
26
|
+
if(yes)return true;
|
|
27
|
+
if(!process.stdin.isTTY||!process.stdout.isTTY)throw Error('not a terminal; run again with --yes to proceed without prompts');
|
|
28
|
+
const prompt=createInterface({input:process.stdin,output:process.stdout});
|
|
29
|
+
try{const answer=(await prompt.question(`${question} ${defaultYes?'[Y/n]':'[y/N]'} `)).trim();
|
|
30
|
+
return defaultYes?!/^n/i.test(answer):/^y/i.test(answer);}finally{prompt.close();}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function setupCommand(args,{entry,cwd=process.cwd()}){
|
|
34
|
+
const action=args.shift();
|
|
35
|
+
const yes=args.includes('--yes'),rest=args.filter(arg=>arg!=='--yes');
|
|
36
|
+
if(action==='setup'&&(rest.length!==1||rest[0]!=='claude'))throw Error('use redue setup claude [--yes]');
|
|
37
|
+
if(action==='remove'&&rest.length)throw Error('use redue remove [--yes]');
|
|
38
|
+
const top=git(cwd,'rev-parse','--show-toplevel');
|
|
39
|
+
if(top.status!==0)throw Error('run this inside a Git checkout');
|
|
40
|
+
const root=fs.realpathSync(top.stdout.trim());
|
|
41
|
+
if(!fs.existsSync(path.join(root,'package.json')))throw Error(`no package.json at the repository root (${root})`);
|
|
42
|
+
// The entry as invoked, so a hook written by a child names the install's
|
|
43
|
+
// stable bin link rather than the versioned file behind it.
|
|
44
|
+
const invoked=typeof process.argv[1]==='string'&&path.isAbsolute(process.argv[1])&&
|
|
45
|
+
(()=>{try{return fs.realpathSync(process.argv[1])===fs.realpathSync(entry);}catch{return false;}})()?process.argv[1]:entry;
|
|
46
|
+
// Every step is the ordinary command, so its own output says what it did.
|
|
47
|
+
const redue=(argv,options={})=>spawnSync(process.execPath,[invoked,...argv],{cwd:root,encoding:'utf8',
|
|
48
|
+
stdio:options.capture?['ignore','pipe','pipe']:'inherit',timeout:options.timeout});
|
|
49
|
+
// One record per checkout, beside the others in the shared Git directory:
|
|
50
|
+
// linked worktrees share .git/info/exclude.
|
|
51
|
+
const common=path.resolve(root,git(root,'rev-parse','--git-common-dir').stdout.trim());
|
|
52
|
+
const recordFile=path.join(common,`redue-setup-${hash(root).slice(0,16)}.json`);
|
|
53
|
+
// Other checkouts' records, for checkouts that still exist.
|
|
54
|
+
const others=()=>{try{return fs.readdirSync(common).filter(name=>/^redue-setup-[0-9a-f]{16}\.json$/.test(name)&&path.join(common,name)!==recordFile)
|
|
55
|
+
.map(name=>JSON.parse(readText(path.join(common,name))??'null')).filter(other=>other&&fs.existsSync(other.root));}catch{return [];}};
|
|
56
|
+
const context={root,redue,yes,record:JSON.parse(readText(recordFile)??'null'),recordFile,
|
|
57
|
+
exclude:path.join(common,'info','exclude'),others,save:next=>fs.writeFileSync(recordFile,JSON.stringify(next,null,2)+'\n')};
|
|
58
|
+
return action==='setup'?setup(context):remove(context);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function setup({root,redue,yes,record:previous,recordFile,exclude,save}){
|
|
62
|
+
if(!fs.existsSync(path.join(root,'node_modules')))
|
|
63
|
+
throw Error('dependencies are not installed: run your package manager\'s install (npm install, pnpm install, yarn) first');
|
|
64
|
+
if(fs.existsSync(path.join(root,'vstate.config.json'))&&!fs.existsSync(path.join(root,configName)))
|
|
65
|
+
throw Error('a legacy vstate.config.json exists; rename it to redue.config.json or remove it first');
|
|
66
|
+
if(git(root,'ls-files','--error-unmatch',localSettings).status===0)
|
|
67
|
+
throw Error(`${localSettings} is committed; the hooks REDUE adds there name this machine's paths. Untrack it (git rm --cached) first`);
|
|
68
|
+
const configPath=path.join(root,configName),configExisted=fs.existsSync(configPath);
|
|
69
|
+
// init's account of the checks: which can qualify, also with a config.
|
|
70
|
+
const preview=redue(['init','--dry-run','--json'],{capture:true});
|
|
71
|
+
if(preview.status!==0)throw Error(preview.stderr.trim().replace(/^redue: /,'')||'init failed');
|
|
72
|
+
const found=JSON.parse(preview.stdout);
|
|
73
|
+
if(!configExisted&&!found.writable)throw Error(`nothing to set up: ${found.issue||(found.checks.length?found.checks.map(check=>`${check.name}: ${check.reason}`).join('; '):'no typecheck, lint or test script REDUE can use')}`);
|
|
74
|
+
const levels=new Map(found.checks.map(check=>[check.name,check.level]));
|
|
75
|
+
const names=configExisted?JSON.parse(readText(configPath)).checks.map(check=>check.name):found.checks.map(check=>check.name);
|
|
76
|
+
const ready=names.filter(name=>levels.get(name)==='ready');
|
|
77
|
+
console.log(`Setting up REDUE for Claude Code in ${root}`);
|
|
78
|
+
console.log(configExisted?` keep ${configName} (exists)`:` create ${configName}: ${names.map(name=>`${name} (${levels.get(name)==='ready'?'can qualify':'recording-only'})`).join(', ')}`);
|
|
79
|
+
console.log(` write the Claude Code integration: CLAUDE.md section, .claude/skills/redue-verification/SKILL.md, ${localSettings} hooks, ${agentRecord}`);
|
|
80
|
+
console.log(` start the observer and record ${ready.length?ready.join(', '):'no check'} once, unless already CURRENT`);
|
|
81
|
+
if(!await confirm('Proceed?',yes,{defaultYes:true})){console.log('Nothing written.');return {applied:false};}
|
|
82
|
+
// What this run creates is added to what earlier runs created.
|
|
83
|
+
const state=projectState(root,configPath);
|
|
84
|
+
const own={schema:1,host:'claude',root,config:configName,state,
|
|
85
|
+
configCreated:Boolean(previous?.configCreated),configHash:previous?.configHash??null,
|
|
86
|
+
agentCreated:Boolean(previous?.agentCreated)||!fs.existsSync(path.join(root,agentRecord)),
|
|
87
|
+
hooksAdded:Boolean(previous?.hooksAdded)||!claudeHooksInstalled(root),
|
|
88
|
+
stateCreated:Boolean(previous?.stateCreated)||!fs.existsSync(state),
|
|
89
|
+
excludeAdded:Boolean(previous?.excludeAdded)};
|
|
90
|
+
if(!configExisted){
|
|
91
|
+
const init=redue(['init']);
|
|
92
|
+
if(init.status!==0||!fs.existsSync(configPath))throw Error(`init did not write ${configName}; nothing else was written`);
|
|
93
|
+
Object.assign(own,{configCreated:true,configHash:hash(readText(configPath))});
|
|
94
|
+
}
|
|
95
|
+
save(own);
|
|
96
|
+
console.log(`${previous?'update':'create'} ${recordFile}: what setup created, for redue remove`);
|
|
97
|
+
const agent=redue(['agent','setup','claude','--apply']);
|
|
98
|
+
if(agent.status!==0)throw Error('agent setup failed; see above. redue remove undoes what setup wrote');
|
|
99
|
+
// The hooks' settings hold machine paths: keep them out of commits in this
|
|
100
|
+
// clone when Git does not ignore them.
|
|
101
|
+
if(git(root,'check-ignore','-q',localSettings).status===1){
|
|
102
|
+
fs.mkdirSync(path.dirname(exclude),{recursive:true});
|
|
103
|
+
const text=readText(exclude)??'';
|
|
104
|
+
fs.writeFileSync(exclude,`${text}${text&&!text.endsWith('\n')?'\n':''}${ignoreLine}\n`);
|
|
105
|
+
console.log(`update ${exclude}: ignore ${ignoreLine} in this clone`);
|
|
106
|
+
own.excludeAdded=true;save(own);
|
|
107
|
+
}
|
|
108
|
+
// An observer that is already running is what start would give.
|
|
109
|
+
const started=redue(['start'],{capture:true});
|
|
110
|
+
process.stdout.write(started.stdout);
|
|
111
|
+
if(started.status!==0){
|
|
112
|
+
if(!/already running/.test(started.stderr))throw Error(`the observer did not start: ${started.stderr.trim().replace(/^redue: /,'')}`);
|
|
113
|
+
console.log('Observer already running.');
|
|
114
|
+
}
|
|
115
|
+
// The observer indexes the project first; a run before that cannot be
|
|
116
|
+
// recorded as reusable. Wait while it plans and indexes; once its plan is
|
|
117
|
+
// ready, an observer that is still unhealthy says why in the status below.
|
|
118
|
+
// Nothing to wait for when no check can qualify.
|
|
119
|
+
const observation=()=>{const out=redue(['status','--json'],{capture:true});try{return JSON.parse(out.stdout).observation??null;}catch{return null;}};
|
|
120
|
+
// An answer without a phase (a stopped observer) for 10 s in a row ends the
|
|
121
|
+
// wait too.
|
|
122
|
+
const begun=Date.now();let told=-1,unread=0,phaseless=0;
|
|
123
|
+
for(let state=observation();ready.length&&!(state?.phase==='ready'||state?.phase==='unavailable')&&phaseless<10;state=observation()){
|
|
124
|
+
const seconds=Math.round((Date.now()-begun)/1000);
|
|
125
|
+
unread=state===null?unread+1:0;phaseless=state&&!state.phase?phaseless+1:0;
|
|
126
|
+
if(unread>=30)throw Error('the observer\'s status cannot be read; run redue status to see why');
|
|
127
|
+
if(seconds>15*60)throw Error(`the observer is not ready after 15 minutes (${state?.reason||state?.phase||'unknown'}); run redue status, then redue setup claude again`);
|
|
128
|
+
if(Math.floor(seconds/15)>told){told=Math.floor(seconds/15);console.log(seconds?`Still indexing (${seconds} s)...`:'Waiting for the observer to index the project...');}
|
|
129
|
+
await new Promise(resolve=>setTimeout(resolve,1000));
|
|
130
|
+
}
|
|
131
|
+
// One recorded run of each check that can qualify and is not CURRENT.
|
|
132
|
+
const statusOf=()=>{const out=redue(['status','--sync','--json'],{capture:true,timeout:600_000});try{return JSON.parse(out.stdout).checks;}catch{return [];}};
|
|
133
|
+
const rows=statusOf(),due=ready.filter(name=>rows.find(row=>row.name===name)?.freshness!=='CURRENT');
|
|
134
|
+
for(const name of due){console.log(`Recording ${name} once:`);redue(['run',name]);}
|
|
135
|
+
if(ready.length&&!due.length)console.log(`Already CURRENT: ${ready.join(', ')}.`);
|
|
136
|
+
for(const row of statusOf())console.log(` ${row.name}: ${row.freshness}${row.result?' / '+row.result:''}${row.freshness==='CURRENT'?'':' — '+row.reason}`);
|
|
137
|
+
console.log('Done. Start a new Claude Code session in this project. Undo with redue remove.');
|
|
138
|
+
return {applied:true};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function remove({root,redue,yes,record:own,recordFile,exclude,others}){
|
|
142
|
+
if(!own){console.log('No setup record for this checkout: nothing redue setup created to remove.');
|
|
143
|
+
console.log('To undo a setup made step by step: redue agent remove claude --apply, then redue remove-state.');return {removed:false};}
|
|
144
|
+
const configPath=path.join(root,configName),configText=readText(configPath);
|
|
145
|
+
const removeAgent=own.agentCreated&&fs.existsSync(path.join(root,agentRecord));
|
|
146
|
+
const removeHooks=!removeAgent&&own.hooksAdded&&claudeHooksInstalled(root);
|
|
147
|
+
const removeState=own.stateCreated&&fs.existsSync(own.state);
|
|
148
|
+
const deleteConfig=own.configCreated&&configText!==null&&hash(configText)===own.configHash;
|
|
149
|
+
// The ignore line goes only when no other checkout of this repository is
|
|
150
|
+
// set up: those rely on it too, whichever added it.
|
|
151
|
+
const removeIgnore=own.excludeAdded&&!others().length;
|
|
152
|
+
console.log(`Removing what redue setup created in ${root}`);
|
|
153
|
+
if(removeAgent)console.log(' remove the Claude Code integration');
|
|
154
|
+
if(removeHooks)console.log(` remove REDUE's hooks from ${localSettings} (the integration predates setup)`);
|
|
155
|
+
if(removeState)console.log(' stop the observer and remove its local state (receipts included)');
|
|
156
|
+
else if(!own.stateCreated)console.log(' keep the observer state (it predates setup)');
|
|
157
|
+
if(own.configCreated&&configText!==null)console.log(deleteConfig?` delete ${configName}`:` keep ${configName} (edited since setup)`);
|
|
158
|
+
if(removeIgnore)console.log(` remove ${ignoreLine} from ${exclude}`);
|
|
159
|
+
console.log(` delete ${recordFile}`);
|
|
160
|
+
if(!await confirm('Proceed?',yes,{defaultYes:false})){console.log('Nothing removed.');return {removed:false};}
|
|
161
|
+
if(removeAgent)for(const line of removeIntegration(root))console.log(line);
|
|
162
|
+
if(removeHooks){const changed=removeClaudeHooks(root);if(changed)console.log(`update ${changed}`);}
|
|
163
|
+
if(removeState&&redue(['--state-dir',own.state,'remove-state']).status!==0)
|
|
164
|
+
throw Error('the observer state was not removed; see above. The config and setup record are kept so you can retry');
|
|
165
|
+
if(deleteConfig){fs.unlinkSync(configPath);console.log(`remove ${configName}`);}
|
|
166
|
+
if(removeIgnore){
|
|
167
|
+
const text=readText(exclude);
|
|
168
|
+
if(text!==null&&text.split('\n').includes(ignoreLine)){
|
|
169
|
+
fs.writeFileSync(exclude,text.split('\n').filter(line=>line!==ignoreLine).join('\n'));
|
|
170
|
+
console.log(`update ${exclude}`);}
|
|
171
|
+
}
|
|
172
|
+
fs.unlinkSync(recordFile);console.log(`remove ${recordFile}`);
|
|
173
|
+
// Folders the integration lived in, when nothing else is left in them.
|
|
174
|
+
for(const dir of ['.redue/agents','.redue','.claude/skills/redue-verification','.claude/skills','.claude'])
|
|
175
|
+
try{fs.rmdirSync(path.join(root,dir));console.log(`remove ${dir}/`);}catch{}
|
|
176
|
+
console.log('Done.');
|
|
177
|
+
return {removed:true};
|
|
178
|
+
}
|
package/src/typescript-list.mjs
CHANGED
|
@@ -63,6 +63,14 @@ export function queriesMatch(queries) {
|
|
|
63
63
|
return queries.every(([kind,file,answer,options])=>
|
|
64
64
|
JSON.stringify(queryAnswer(kind,file,options))===JSON.stringify(answer));
|
|
65
65
|
}
|
|
66
|
+
// Why a recipe built on TypeScript's compiler API cannot use the TypeScript
|
|
67
|
+
// installed at `tsRoot`, or null: TypeScript 7 is a native compiler and
|
|
68
|
+
// ships no such API.
|
|
69
|
+
export function compilerApiIssue(tsRoot) {
|
|
70
|
+
let version=null;try{version=JSON.parse(fs.readFileSync(path.join(tsRoot,'package.json'),'utf8')).version;}catch{}
|
|
71
|
+
return Number(String(version).split('.')[0])>=7?
|
|
72
|
+
`TypeScript ${version} is a native compiler without the compiler API this recipe reads; REDUE does not support TypeScript 7 yet`:null;
|
|
73
|
+
}
|
|
66
74
|
export function compilerFiles(tsRoot,cwd,config='tsconfig.json') {
|
|
67
75
|
const require=createRequire(import.meta.url),ts=require(tsRoot);
|
|
68
76
|
if(typeof ts.createProgram!=='function'||typeof ts.createCompilerHost!=='function')return null;
|
|
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import {execFileSync} from 'node:child_process';
|
|
4
4
|
import {createRequire} from 'node:module';
|
|
5
|
+
import {compilerApiIssue} from './typescript-list.mjs';
|
|
5
6
|
import micromatch from './glob.mjs';
|
|
6
7
|
import {withinPath as within,realObservedPath} from './path-identity.mjs';
|
|
7
8
|
|
|
@@ -58,6 +59,8 @@ export function yarnWorkspacePackages(root){
|
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
function parsedTypeScript(root,dir){
|
|
62
|
+
const apiIssue=compilerApiIssue(path.join(root,'node_modules','typescript'));
|
|
63
|
+
if(apiIssue)throw Error(apiIssue);
|
|
61
64
|
const require=createRequire(path.join(root,'package.json'));
|
|
62
65
|
const ts=require(path.join(root,'node_modules','typescript'));
|
|
63
66
|
const config=path.join(root,dir,'tsconfig.json'),reads=new Set();
|