pi-checkup 0.0.0-stage → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xinghe-labs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,344 @@
1
- # Temporary Holding Version
1
+ # pi-checkup
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **`pi list` tells you what you installed. `pi-checkup` tells you what actually works.**
4
+
5
+ A health-check extension for [Pi](https://pi.dev). It inspects the *running* session — not just your `settings.json` — and reports the failures that produce no error message anywhere: tools silently shadowed by another package, binaries that resolve to a completely different program, packages that are installed but registered nothing, endpoints that stopped answering.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pi install npm:pi-checkup
11
+ ```
12
+
13
+ Start a new session afterwards (or run `/reload` in a running one): a package
14
+ installed mid-session is not loaded until Pi re-reads its package list.
15
+
16
+ Then, in a session:
17
+
18
+ ```text
19
+ /checkup run every check
20
+ /checkup verbose same, plus what to do about each finding
21
+ /checkup json machine-readable report
22
+ /checkup fix apply the available repairs (asks first)
23
+ ```
24
+
25
+ ## The problem
26
+
27
+ A Pi install accumulates packages. Each one registers tools, commands, hooks, and sometimes shell binaries. When one of them stops working, nothing tells you:
28
+
29
+ - Two packages claimed the same tool name, and the second one silently won. The first is now unreachable.
30
+ - A package is in `settings.json`, its directory exists, but its manifest points at a file that isn't there. It loaded nothing and logged nothing.
31
+ - `ast-grep` ships a binary named `sg`. So does the shadow-utils package. Typing `sg` starts the wrong one.
32
+ - A blocked `postinstall` left `node_modules/.bin/playwright` as a dangling symlink.
33
+ - Your provider endpoint moved. The only symptom is a request that hangs, then fails.
34
+
35
+ `pi list` prints paths. `pi --verbose` prints warnings you have to read line by line. Neither answers *"is my setup actually coherent right now?"*
36
+
37
+ ## The standalone CLI
38
+
39
+ The in-session command is useless in the one case you need it most: Pi is installed but will not boot. The same checks also ship as a CLI that needs no session, no Pi, and no `node_modules` — which is also what you want in CI, or on a machine you are about to hand over.
40
+
41
+ One difference to know before you read the first CLI report: the registry section — collisions, silent packages, attribution — is read from the live process, so the CLI marks it skipped. Everything else runs without a session: packages, binaries, network, config, runtime. On a machine where Pi has never been started, most rows have nothing to inspect and say so rather than passing.
42
+
43
+ ```bash
44
+ npx pi-checkup # full report
45
+ npx pi-checkup --verbose # same report, plus the hint under every finding
46
+ npx pi-checkup --offline # skip every network probe
47
+ npx pi-checkup --json # for scripts
48
+ npx pi-checkup --fix # list the available repairs
49
+ npx pi-checkup --fix -y # apply them (nothing is written without -y)
50
+ npx pi-checkup --version # print the version
51
+ npx pi-checkup --help # every flag and exit code
52
+ ```
53
+
54
+ Requires Node 20 or newer (`engines` in `package.json`); no dependencies, no `node_modules`.
55
+
56
+ It exits `1` when any check fails, so it works as a pre-flight step in CI or a shell profile. After a repair it re-runs the checks and exits on the new result, so `npx pi-checkup --fix -y && start-pi` does what it looks like it does. `--json` cannot be combined with `--fix`: the JSON is a single snapshot, and a repair changes what the snapshot describes.
57
+
58
+ The registry section reports skipped — auditing loaded tools genuinely requires a live process — and the CLI says so instead of implying a clean bill of health.
59
+
60
+ Exit codes: `0` nothing failed, `1` at least one check failed, `2` bad usage or a crashed run. The `--json` payload is the same report the text renderer prints, minus the repair metadata.
61
+
62
+ | Variable | Effect |
63
+ |---|---|
64
+ | `PI_CODING_AGENT_DIR` | Check a different Pi install than `~/.pi/agent`. The CLI needs no session, so this is how you check a container, a second profile, or a Pi that will not boot at all |
65
+ | `PI_CHECKUP_IGNORE` | Suppress rows you have decided to live with — see below |
66
+ | `PI_CHECKUP_PROBE_URL` | Test real proxy *forwarding* with `curl`, instead of only proving the port is open. Only meaningful when a proxy variable below is set — without one there is nothing to forward through |
67
+ | `https_proxy`, `HTTPS_PROXY`, `http_proxy`, `HTTP_PROXY`, `all_proxy`, `ALL_PROXY` | The first one set is used by the provider probe (through `curl`, because Node's `fetch` ignores these) and reported redacted under Runtime. The probe runs with `--noproxy ''`, so a `NO_PROXY` entry cannot route it around the proxy it is meant to test — and Pi's own `fetch` ignores `NO_PROXY` too |
68
+
69
+ ```text
70
+ $ npx pi-checkup
71
+ Registry
72
+ · live registry no Pi session — registry checks need a running process
73
+ Packages
74
+ ✓ package dirs 14 present
75
+ ✓ manifests 14 readable
76
+ ✓ entry files 13 manifest(s) resolve
77
+ Binaries
78
+ ✓ dangling links 19 links all resolve
79
+ · shadowed names no package command resolved on PATH
80
+ ! unreachable bins 6 package command(s) not on PATH: pi-web-access-mcp,
81
+ pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp,
82
+ pi-lens-analyze
83
+ Network
84
+ ✓ providers 2 endpoint(s) reachable
85
+ ✓ dns 1 provider host(s) resolve normally
86
+ Config
87
+ ✓ settings.json valid JSON
88
+ ✓ models.json valid JSON
89
+ · project settings none
90
+ ✓ backups 2 file(s) backed up; edited since the newest backup:
91
+ settings.json
92
+ ✓ duplicate packages none
93
+ · env references no $VAR reference in models.json credentials
94
+ Runtime
95
+ ✓ node v22.23.3
96
+ ✓ pi launcher /root/.pi/agent/bin/pi
97
+ ✓ disk 6% used on /, 950.7 GB free
98
+ ✓ proxy http://127.0.0.1:10808 accepting connections
99
+
100
+ 19 checks: 14 ok, 1 warning, 0 failed, 4 skipped
101
+ Run `npx pi-checkup --verbose` to show what to do about each finding.
102
+
103
+ ! 1 warning, 4 skipped · run /checkup inside Pi for registry checks
104
+ ```
105
+
106
+ Both captures below and above come from a real machine, taken with pi-checkup 0.1.0; free space, Node versions and which packages are installed are properties of that machine, not of the tool, and the row counts grow when checks are added.
107
+
108
+ ## Real output
109
+
110
+ This is an actual run on a working install, copied out of the TUI at 80 columns — not a mock-up. The two `Network` rows describe the moment it ran; everything else is a property of the install. 20 of 24 checks pass, three are skipped because there is genuinely nothing to look at, and the one warning is the expected state on this machine — the package commands are installed but their directory is not on `PATH`, which the hint explains and `PI_CHECKUP_IGNORE=unreachable` silences.
111
+
112
+ ```text
113
+ Registry
114
+ ✓ tools 49 registered (39 extension, 10 builtin) across 12
115
+ sources
116
+ ✓ commands 95 registered across 21 sources, 0 MCP server(s)
117
+ ✓ attribution 49 tool(s) from 12 source(s): pi-lens (13), builtin (10),
118
+ pi-web-access (5), pi-subagents (5), ... 8 more
119
+ ✓ tool collisions none
120
+ ✓ command collisions none
121
+ ✓ silent packages none
122
+ Packages
123
+ ✓ package dirs 14 present
124
+ ✓ manifests 14 readable
125
+ ✓ entry files 13 manifest(s) resolve
126
+ Binaries
127
+ ✓ dangling links 19 links all resolve
128
+ · shadowed names no package command resolved on PATH
129
+ ! unreachable bins 6 package command(s) not on PATH: pi-web-access-mcp,
130
+ pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp,
131
+ pi-lens-analyze
132
+ Network
133
+ ✓ providers 2 endpoint(s) reachable
134
+ ✓ dns 1 provider host(s) resolve normally
135
+ Config
136
+ ✓ settings.json valid JSON (values read from the running session)
137
+ ✓ models.json valid JSON
138
+ · project settings none
139
+ ✓ backups 2 file(s) backed up; edited since the newest backup:
140
+ settings.json
141
+ ✓ duplicate packages none
142
+ · env references no $VAR reference in models.json credentials
143
+ Runtime
144
+ ✓ node v22.23.3
145
+ ✓ pi launcher /root/.pi/agent/bin/pi
146
+ ✓ disk 6% used on /, 950.7 GB free
147
+ ✓ proxy http://127.0.0.1:10808 accepting connections
148
+
149
+ 24 checks: 20 ok, 1 warning, 0 failed, 3 skipped
150
+ Run `/checkup verbose` to show what to do about each finding.
151
+ ```
152
+
153
+ The `attribution` row is the part no other tool gives you: which package owns which tools, counted from the live registry rather than from your config. Both blocks above share the same 23-column detail column, because `duplicate packages` and `command collisions` are both 18 characters wide and the renderer sizes the column to the longest title in the report it is printing, up to a 22-character cap after which the detail moves to its own line.
154
+
155
+ ## What it checks
156
+
157
+ | Group | Finding | Why it matters |
158
+ |---|---|---|
159
+ | **Registry** | Live registry | No Pi session — the registry checks need a running process, so the whole section collapses to this one skipped row |
160
+ | | Tool and command collisions | One extension silently stops being reachable; the later registration wins with no error |
161
+ | | Silent packages | Declared an extension but registered no tool or command — either by design or a load failure |
162
+ | | Loaded tools and commands | How many registered, split by extension and builtin, and how many distinct sources they came from |
163
+ | | Attribution | Which package owns each of your tools, by name |
164
+ | **Packages** | Package list | Whether `settings.json` could be read at all — a hand-edited file that does not parse is reported as unreadable, not as "no packages" |
165
+ | | Missing directories | In `settings.json` but gone from `node_modules`. A version pin (`npm:name@1.2.3`) is resolved to the directory it installs into, not taken as part of the name |
166
+ | | Version pins | `settings.json` pins an exact version and a different one is installed — the code that runs is not the code that was asked for. Ranges and tags are not compared, because the installer is free to satisfy them |
167
+ | | Corrupt manifests | A `package.json` that parses as nothing — reported as a failure, because the package cannot load |
168
+ | | Missing manifests | The package directory exists but holds no `package.json` — an interrupted install, or a leftover folder. Its own row, because telling someone to check ownership of a directory that has no manifest sends them to fix the wrong thing |
169
+ | | Unreadable manifests | A `package.json` this process could not read (permissions, or larger than the 4 MB limit). Reported as a warning: unproven, not broken |
170
+ | | Missing entry files | The manifest points at a build output that was never shipped |
171
+ | | Not a package | A `packages` entry that points at a file instead of a directory. Reported without a repair: there is nothing to delete |
172
+ | | Unverifiable sources | `git:` and `https:` packages, which Pi resolves internally and no static check can follow |
173
+ | | Malformed entries | A `packages` entry that is not a source string — `null`, a number, an object without `source` |
174
+ | | Unreadable directories | A package directory that exists but cannot be listed, which is a permission problem rather than a missing install |
175
+ | | Manifest shape | A `pi.extensions` entry that is not a string. The string entries are still checked; only the malformed ones are left out |
176
+ | **Binaries** | Bin directory | Whether `.bin` could be listed at all — a missing `.bin` is a `skip` that still says how many commands it should have held, an unreadable one is a warning |
177
+ | | Dangling links | A symlink whose target is gone — a blocked `postinstall`, or an optional native dependency skipped for this platform. Only links are counted, so a Windows `.bin` of plain shim files reports `skip` instead of a green tick over nothing |
178
+ | | Unreadable links | A link that exists but cannot be followed (permissions, a loop) — reported, not called broken |
179
+ | | Install leftovers | `.<name>-<random>` staging links in `.bin`: an install is running right now, or was interrupted. Its own finding, not a broken package |
180
+ | | Missing shims | The package declares a `bin` but npm never linked it into `.bin` — usually an install run with `--ignore-scripts` |
181
+ | | Shadowed names | A package's command name resolves to an unrelated program earlier on `PATH`. Skipped when no declared command resolved at all: there is nothing to compare, and a green tick there would sit next to a row saying the commands are missing |
182
+ | | Unreachable bins | A package ships a CLI you cannot invoke by name |
183
+ | **Network** | Endpoint reachability | Probes `<baseUrl>/models` for every provider. `401`/`403` counts as alive — this never validates your key and never spends tokens. An endpoint that answers with something other than life — a refused connection, a rejected certificate, an HTTP error — is a failure, and fails the run; only a probe that was cut off locally by its own timeout stays a warning, because the deadline is this process's, not the endpoint's |
184
+ | | Cleartext auth | A provider on `http://` to another machine with a credential configured — an `apiKey`, a password embedded in the `baseUrl`, a credential-named header value, or a query string, which cannot be inspected for secrets. The `apiKey` and `baseUrl` credentials are withheld from the probe; a query string is never sent, so that endpoint is reported unprobed. Loopback hosts are exempt |
185
+ | | baseUrl shape | A `baseUrl` that is not a string or an object. A string that is not a URL — including the `localhost:11434/v1` paste with no scheme — is reported as an unhealthy provider, and also named in this row, because the endpoint is what would have to be probed |
186
+ | | DNS | Provider hosts that do not resolve, or resolve only to loopback |
187
+ | **Config** | Invalid JSON | With a backup on disk, `/checkup fix` can restore it |
188
+ | | Duplicate package entries | The package loads twice and the second registration can shadow the first |
189
+ | | Unset env references | `$NAME` / `${NAME}` in the `models.json` fields Pi actually interpolates (`apiKey`, header values) with nothing in the environment |
190
+ | | Backups | Whether a broken config could be recovered at all |
191
+ | **Runtime** | Agent directory | Whether the directory being inspected exists and can be listed — without it most other rows skip, which would otherwise read as a clean install |
192
+ | | Node version, launcher, disk, proxy | The host conditions Pi itself needs |
193
+
194
+ The groups above are the ones that appear in a report, in that order. Independent of them: a check that throws, or runs past 30 seconds, is reported as a failure naming the module, and every other section still prints.
195
+
196
+ ## `/checkup fix`
197
+
198
+ Only reversible repairs are automated, and inside a session it normally offers only the first:
199
+
200
+ 1. **Restore a config file** that is no longer valid JSON, from its newest sibling backup. The broken copy is kept.
201
+
202
+ 2. **Collapse duplicate `packages` entries** in `settings.json`. Only byte-identical repeats are dropped: `"npm:a"` next to `{"source": "npm:a", "enabled": false}` carries different intent and is left alone.
203
+ 3. **Prune `packages` entries whose directory is gone** — the ones a reinstall did not bring back. Sources resolved by Pi itself (`git:`, `https:`) are never touched, and a prune that would leave the list empty is refused and never even offered: that pattern means the agent directory is wrong, not that every package vanished.
204
+
205
+ Repairs 2 and 3 rewrite `settings.json`, so they need a settings file checkup actually read. Inside a session Pi hands checkup the settings from the *running process*, not the file on disk, and checkup will not rewrite a file it never read — so `/checkup fix` in-session offers repair 1, plus the other two only when Pi returned no settings and checkup read the file itself. If `getSettings()` threw, the fallback is still unverified and neither rewrite is offered. Stop Pi and run the CLI to be sure:
206
+
207
+ ```bash
208
+ npx pi-checkup --fix --yes
209
+ ```
210
+
211
+ Every write is preceded by a timestamped backup, and neither path writes on its own: `/checkup fix` asks in the TUI, and the CLI refuses to write unless you pass `--yes`. Anything that would delete a package, disable an extension, or edit your shell environment is reported with a suggestion and left alone — a diagnostic tool should not be able to break your setup in the process of describing it.
212
+
213
+ A repair that rewrites `settings.json` re-serialises it with two-space indentation, so expect a larger diff than the change itself. Nothing is repaired unless a check found something wrong, so a healthy install writes nothing at all.
214
+
215
+ ## Ignoring a finding you have decided to live with
216
+
217
+ Some warnings are correct and permanent: a launcher you deliberately keep off `PATH`, an event-only package that registers no tool. A health check you have learned to ignore is worse than no health check, so rows can be suppressed by name.
218
+
219
+ ```bash
220
+ PI_CHECKUP_IGNORE=unreachable,Config:env npx pi-checkup
221
+ ```
222
+
223
+ Each entry matches a row's `id`, its `Group:id` form, or its title. A suppressed row is downgraded to `skip`, keeps its original detail, and says what it replaced:
224
+
225
+ ```text
226
+ · unreachable bins ignored via PI_CHECKUP_IGNORE (would be warn): 6 package
227
+ command(s) not on PATH: pi-web-access-mcp, pi-subagents,
228
+ pi-mcp-adapter, pi-lens, pi-lens-mcp, pi-lens-analyze
229
+ ```
230
+
231
+ Suppression is never silent, and it drops the row's repair offer so a hidden finding cannot be fixed behind your back. It applies to failures too: a suppressed `fail` no longer affects the exit code, which is the point of the variable — use it for a state you have decided to accept, not to turn a red build green. Export it from your shell profile if you want it to apply inside Pi too.
232
+
233
+ ## JSON output
234
+
235
+ `/checkup json` prints the report as one object, delivered as a TUI notification — good for pasting into a bug report. For a pipe, use `npx pi-checkup --json`, which writes the same object to stdout, stamped with the version and platform that produced it. The excerpt below is from `/checkup json` inside a session — 24 entries, because the registry section has something to read there; in the CLI that section collapses to a single skipped row. Two of the entries, and the summary, are shown here:
236
+
237
+ ```json
238
+ {
239
+ "summary": {
240
+ "total": 24,
241
+ "ok": 20,
242
+ "warn": 1,
243
+ "fail": 0,
244
+ "skip": 3
245
+ },
246
+ "version": "0.1.0",
247
+ "node": "22.23.3",
248
+ "platform": "linux",
249
+ "checks": [
250
+ {
251
+ "group": "Binaries",
252
+ "id": "unreachable",
253
+ "title": "unreachable bins",
254
+ "status": "warn",
255
+ "detail": "6 package command(s) not on PATH: pi-web-access-mcp, pi-subagents, pi-mcp-adapter, pi-lens, pi-lens-mcp, pi-lens-analyze",
256
+ "hint": "Pi installs package commands into /root/.pi/agent/npm/node_modules/.bin without adding it to PATH. Extensions that call them by full path still work; to use them by name, add that directory to PATH or run them with npx. If this is the state you want, silence the row with PI_CHECKUP_IGNORE=unreachable."
257
+ },
258
+ {
259
+ "group": "Config",
260
+ "id": "env",
261
+ "title": "env references",
262
+ "status": "skip",
263
+ "detail": "no $VAR reference in models.json credentials"
264
+ }
265
+ ]
266
+ }
267
+ ```
268
+
269
+ The `checks` array always has one entry per row of the text report, in the same order. The excerpt above omits the other 22 entries; real output is printed by `JSON.stringify(payload, null, 2)`.
270
+
271
+ Exit-worthy statuses are `fail`; `warn` means something needs attention without by itself failing the run — a package command that is not on `PATH`, or an `apiKey` that no HTTP request can carry. Repair metadata is stripped from the JSON payload.
272
+
273
+ ## Design principles
274
+
275
+ - **No traffic to anyone but your own provider.** The only outbound requests are `<baseUrl>/models` for providers already in `models.json`, plus a DNS lookup of those same hosts. The proxy check is a plain TCP connect to the proxy's own address — set `PI_CHECKUP_PROBE_URL` if you want a real end-to-end forwarding test.
276
+ - **No secrets printed, no keys validated.** A provider probe sends the `apiKey` — or the `baseUrl` credentials, moved into an `Authorization` header — that your config defines, and ignores the response body. Custom `headers` are not replayed. A key on a plain-`http://` endpoint that is not on your own machine is never sent, and neither is a query string there — `?key=…` gateways are a real shape, and a query cannot be inspected for secrets. A proxy URL is reduced to scheme, host and port, and a value that is nothing but a credential prints `(configured)`; a `git:` source carrying a token is scrubbed the same way: corporate proxies and private repos both keep credentials in the URL, and both are the kind of string that ends up in a CI log. A `baseUrl` with credentials in it is moved into a header before the probe, so no error message can quote it back; over plain `http://` to another machine it is dropped instead, and the row says so. A header value that cannot be sent at all — a newline, a character above Latin-1 — is never sent through either path and is never quoted back: the row says the request could not be built and points at `models.json`.
277
+ - **Non-blocking.** Every check runs concurrently, and every outbound call has its own timeout — 6s for a curl probe, 8s for a fetch probe, 4s for DNS, 3s for a TCP connect — under a 30s per-module backstop. A hung endpoint costs one timeout, or two when a configured proxy fails and the direct path is tried as well, and never the whole command.
278
+ - **A crashing check is a finding, not a crash.** If a check module throws, the report says which one, marks it failed, and still shows everything else.
279
+ - **A check with nothing to inspect says so.** `skip` means nothing was inspected — an absent optional config file, an empty `.bin`, a provider list with no host to resolve, or a row you suppressed with `PI_CHECKUP_IGNORE` (whose detail says what it replaced). Zero packages present reports `skip`, never a green tick over nothing, and the verdict counts them: `✓ 3 ok, 2 skipped` rather than a bare "all clear".
280
+ - **Cross-platform.** Node built-ins only. `PATH` is resolved in-process against `PATHEXT` on Windows and the executable bit on POSIX, so a minimal container without `which` still works; `/dev/null` vs `NUL` for curl output, symlinked package stores, and path separators are handled. A check that finds nothing to inspect is marked skipped rather than failed — but a check module that throws or runs past its 30s backstop is a failure, because something was supposed to be inspected and was not.
281
+ - **Zero dependencies.** Nothing to audit, nothing to break on a Pi upgrade.
282
+
283
+ ## Known limitations
284
+
285
+ Being explicit about what this cannot see:
286
+
287
+ - **Extension load errors are not exposed to extensions.** Pi's own `LoadExtensionsResult.errors` is not reachable from the extension API, so "which extension threw during load" is inferred statically (declared but unregistered), not read directly.
288
+ - **`git:` and URL package sources are not inspected.** Pi resolves them internally; static probing cannot follow them, so they are reported as `unverifiable` rather than guessed at.
289
+ - **Only the personal agent directory is audited.** A project's `.pi/settings.json` is checked for parseability, but the packages it declares are not resolved: where a project installs them is Pi's decision, and guessing the path produced "missing" claims that were not true.
290
+ - **The registry section needs a running session.** Tools and commands are read from the live process; the CLI reports that section as skipped rather than guessing from disk.
291
+ - **Tool collisions are reported, not resolved.** Pi's registry keeps the last registration; checkup tells you it happened.
292
+ - **The proxy check proves the port is open, not that forwarding works.** A TCP connect tells you the process is running; it says nothing about whether it can reach a given target. Set `PI_CHECKUP_PROBE_URL` for the full test, which uses `curl`.
293
+ - **A proxied provider probe needs `curl`.** Node's `fetch` ignores `https_proxy`, so when a proxy is configured the probe shells out to `curl`, which honours it. Without `curl` on `PATH` that row reports `skip` with the reason instead of inventing a reachability failure.
294
+ - **Static checks describe the current session.** Install a package and the registry section is stale until `/reload`.
295
+ - **A DNS lookup that hangs can outlive the report.** `resolveHost` races the lookup against a 4s deadline so the report is never held hostage, but Node's pending `getaddrinfo` request can keep the process alive for a while after the verdict is printed. The report is complete and correct; the exit is merely later than it looks.
296
+ - **A run that overlaps an install describes a moving target.** Half-extracted packages and npm's staging links in `.bin` are visible for a second or two; checkup reports them as install leftovers and suggests re-running once the install finishes, but it cannot wait for it.
297
+
298
+ ## Development
299
+
300
+ ```bash
301
+ git clone https://github.com/xinghe-labs/pi-checkup
302
+ cd pi-checkup
303
+ npm run lint # node --check over every source file
304
+ node --test # the whole suite; no Pi session needed
305
+ ```
306
+
307
+ To try a local checkout without installing:
308
+
309
+ ```bash
310
+ pi -e ./src/index.js
311
+ ```
312
+
313
+ Layout:
314
+
315
+ ```text
316
+ bin/
317
+ checkup.js standalone CLI entry
318
+ src/
319
+ index.js in-session command registration, fix flow
320
+ context.js where a check gets its inputs (session vs disk)
321
+ render.js one formatter for text and JSON
322
+ repair.js the only module that writes to user files
323
+ util.js cross-platform helpers, no dependencies
324
+ checks/
325
+ index.js the check registry, shared by command and CLI
326
+ registry.js what is loaded, who owns it, what collided
327
+ packages.js installed vs present vs loadable
328
+ binaries.js PATH reachability and shadowing
329
+ network.js provider endpoints and DNS
330
+ config.js JSON validity, backups, duplicates, env refs
331
+ runtime.js node, launcher, disk, proxy
332
+ test/
333
+ *.test.js node --test, no Pi session needed
334
+ scripts/
335
+ lint.js node --check over every source file (`npm run lint`)
336
+ ```
337
+
338
+ Adding a check: export `group` and `run(ctx)`, return an array of `{ group, id, title, status, detail, hint? }`, and add the module to the `CHECKS` array in `src/checks/index.js`.
339
+
340
+ `ctx` carries everything a check may read — `agentDir`, `settingsFile`, `projectSettingsFile`, `modelsFile`, `binDir`, `settings`, `settingsLoaded`, `settingsSource`, `env`, `offline`, `hasRuntime`, `agentDirExplicit`, `pi`, `cwd` — so no check reads the process environment or a path on its own. Tests point `agentDir` at a temp directory; see `test/checks.test.js`.
341
+
342
+ ## License
343
+
344
+ MIT
package/bin/checkup.js ADDED
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env node
2
+ // Standalone CLI — for the case the in-session command cannot cover: Pi is
3
+ // installed but will not start, so there is no session to run /checkup in.
4
+ //
5
+ // The registry checks need a live process and report skipped here. Everything
6
+ // else (packages, binaries, config, runtime, and network unless --offline) runs
7
+ // exactly as it does inside Pi.
8
+ import { runChecks } from "../src/checks/index.js";
9
+ import { buildContext } from "../src/context.js";
10
+ import { renderReport, renderVerdict, toJson } from "../src/render.js";
11
+ import { applyRepairs } from "../src/repair.js";
12
+ import { packageVersion, scrubCredentials } from "../src/util.js";
13
+
14
+ const VERSION = packageVersion();
15
+
16
+ const HELP = `pi-checkup ${VERSION} — health checks for a Pi installation
17
+
18
+ Usage
19
+ pi-checkup [options]
20
+
21
+ Options
22
+ -v, --verbose show how to fix each finding
23
+ --json print the report as JSON
24
+ --offline skip every network probe
25
+ --fix list the available repairs, and apply them with --yes
26
+ -y, --yes apply the repairs instead of only listing them
27
+ -h, --help show this help
28
+ --version print the version
29
+
30
+ Exit codes
31
+ 0 no failed checks (including after a successful repair)
32
+ 1 at least one check failed
33
+ 2 usage error, or the run itself crashed
34
+
35
+ --json and --fix cannot be combined: --json is a single machine-readable
36
+ snapshot, and a repair changes what the snapshot describes.
37
+
38
+ The registry section needs a running Pi session; use /checkup inside Pi to
39
+ audit loaded tools and commands.
40
+
41
+ Environment
42
+ PI_CODING_AGENT_DIR agent directory to inspect (default: ~/.pi/agent)
43
+ PI_CHECKUP_IGNORE comma-separated rows to downgrade to skipped, by id,
44
+ Group:id, or title (e.g. "unreachable,Config:env")
45
+ PI_CHECKUP_PROBE_URL opt in to a real forwarding test through the proxy
46
+ https_proxy, HTTPS_PROXY, http_proxy, HTTP_PROXY, all_proxy, ALL_PROXY
47
+ the first one set is tested for reachability; never
48
+ printed with credentials`;
49
+
50
+ function parseArgs(argv) {
51
+ const opts = { verbose: false, json: false, offline: false, fix: false, yes: false, help: false, version: false };
52
+ for (const arg of argv) {
53
+ switch (arg) {
54
+ case "-v":
55
+ case "--verbose":
56
+ opts.verbose = true;
57
+ break;
58
+ case "--json":
59
+ opts.json = true;
60
+ break;
61
+ case "--offline":
62
+ opts.offline = true;
63
+ break;
64
+ case "--fix":
65
+ opts.fix = true;
66
+ break;
67
+ case "--yes":
68
+ case "-y":
69
+ opts.yes = true;
70
+ break;
71
+ case "-h":
72
+ case "--help":
73
+ opts.help = true;
74
+ break;
75
+ case "--version":
76
+ opts.version = true;
77
+ break;
78
+ default:
79
+ throw new Error(`unknown option: ${arg}`);
80
+ }
81
+ }
82
+ // Silently ignoring one of these two would run no repairs and say nothing.
83
+ if (opts.json && opts.fix) throw new Error("--json cannot be combined with --fix");
84
+ return opts;
85
+ }
86
+
87
+ async function main() {
88
+ let opts;
89
+ try {
90
+ opts = parseArgs(process.argv.slice(2));
91
+ } catch (e) {
92
+ process.stderr.write(`${e.message}\n\n${HELP}\n`);
93
+ return 2;
94
+ }
95
+
96
+ if (opts.help) {
97
+ process.stdout.write(`${HELP}\n`);
98
+ return 0;
99
+ }
100
+ if (opts.version) {
101
+ process.stdout.write(`${VERSION}\n`);
102
+ return 0;
103
+ }
104
+
105
+ // `--yes` on its own reads as "apply the repairs" and does nothing. Say so
106
+ // rather than returning a normal report that silently ignored a flag.
107
+ if (opts.yes && !opts.fix) process.stderr.write("pi-checkup: --yes only means something with --fix\n");
108
+
109
+ // Whether the agent directory was named explicitly matters to the Runtime
110
+ // group: a path the user typed and got wrong is a fault in this run, while an
111
+ // absent default location only means Pi has not been started on this machine.
112
+ const ctx = buildContext({
113
+ cwd: process.cwd(),
114
+ offline: opts.offline,
115
+ agentDirExplicit: Boolean(process.env.PI_CODING_AGENT_DIR),
116
+ });
117
+ const results = await runChecks(ctx);
118
+
119
+ if (opts.json) {
120
+ // Stamped: a JSON report pasted into an issue has to say what produced it,
121
+ // and a version or a platform is the first thing anyone asks for.
122
+ const payload = toJson(results, { version: VERSION, node: process.versions.node, platform: process.platform });
123
+ // Set before the write, like the text path: a consumer that closes the pipe
124
+ // mid-payload must still see the code the report implies.
125
+ process.exitCode = results.some((r) => r.status === "fail") ? 1 : 0;
126
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
127
+ return process.exitCode;
128
+ }
129
+
130
+ const report = renderReport(results, {
131
+ verbose: opts.verbose,
132
+ // `pi-checkup` alone only resolves after a global install the README does
133
+ // not require, so the hint names the form that always works.
134
+ hintCommand: "npx pi-checkup --verbose",
135
+ // Piped output has no columns; the renderer's default keeps logs stable.
136
+ width: process.stdout.isTTY ? process.stdout.columns || 80 : 80,
137
+ });
138
+ // Set before the first byte of the report, so a consumer that closes the pipe
139
+ // mid-report (`pi-checkup | head -3`) still exits with the code the report
140
+ // implies instead of a misleading 0.
141
+ process.exitCode = results.some((r) => r.status === "fail") ? 1 : 0;
142
+ process.stdout.write(`${report}\n`);
143
+ const fixable = results.filter((r) => r.fix && r.fixTarget);
144
+ let finalResults = results;
145
+ if (opts.fix) {
146
+ if (!fixable.length) {
147
+ process.stdout.write("\nNothing to repair automatically.\n");
148
+ } else if (!opts.yes) {
149
+ process.stdout.write(`\n${fixable.length} repair(s) available — each rewrites a config file after backing it up:\n`);
150
+ for (const f of fixable) process.stdout.write(` • ${f.title}: ${f.detail}\n`);
151
+ process.stdout.write("\nRe-run with --fix --yes to apply them. Every file is backed up first.\n");
152
+ } else {
153
+ const applied = await applyRepairs(fixable, { agentDir: ctx.agentDir });
154
+ process.stdout.write("\nRepairs\n");
155
+ for (const a of applied) process.stdout.write(` ${a.ok ? "✓" : "✗"} ${a.message}\n`);
156
+ // Only when something was actually written: a refused repair, or one
157
+ // that found nothing to do by the time it ran, changed nothing, so
158
+ // telling the user to restart Pi is noise.
159
+ const wrote = applied.some((a) => a.changed);
160
+ if (wrote) process.stdout.write("\nRestart Pi so it picks up the changes.\n");
161
+ // Re-check: the exit code has to describe the tree as it is now, not as
162
+ // it was before the repair. Exiting 1 right after fixing the only
163
+ // failure breaks every `&&` chain that calls this.
164
+ //
165
+ // The context has to be rebuilt, not reused: it holds the settings it
166
+ // parsed on construction, so re-running the checks against the old one
167
+ // reports the entries the repair just removed. That is how a successful
168
+ // prune still exited 1.
169
+ if (wrote) {
170
+ finalResults = await runChecks(
171
+ buildContext({
172
+ cwd: process.cwd(),
173
+ offline: opts.offline,
174
+ agentDirExplicit: Boolean(process.env.PI_CODING_AGENT_DIR),
175
+ }),
176
+ );
177
+ }
178
+ }
179
+ } else if (fixable.length) {
180
+ process.stdout.write(`\n${fixable.length} finding(s) can be repaired with --fix.\n`);
181
+ }
182
+
183
+ // Discoverability: the CLI cannot audit the registry, and saying so is more
184
+ // useful than letting the skipped section look like a clean bill of health.
185
+ // Labelled, because an unlabelled verdict printed under a pre-repair report
186
+ // reads as a contradiction: "1 failed" then "✓ 23 ok".
187
+ const after = finalResults === results ? "" : " · re-checked after repair";
188
+ process.stdout.write(`\n${renderVerdict(finalResults)}${after} · run /checkup inside Pi for registry checks\n`);
189
+ return finalResults.some((r) => r.status === "fail") ? 1 : 0;
190
+ }
191
+
192
+ // `npx pi-checkup | head -3` closes stdout while the report is still being
193
+ // written. Node surfaces that as an unhandled EPIPE rejection, so a truncated
194
+ // pipe printed "pi-checkup crashed" with a stack for something that is not a
195
+ // failure of the check at all. A consumer that stopped reading is a normal exit.
196
+ // Anything else is a real write failure, and it goes through the same scrubbing
197
+ // as a caught exception: an uncaught throw from an event listener printed a raw
198
+ // stack, which is the one place a config value can slip out unredacted.
199
+ let reported = false;
200
+ const writeFailure = (e) => {
201
+ // A write error on stderr is itself reported to stderr, so without this guard
202
+ // a broken stderr re-enters the handler instead of exiting.
203
+ if (reported) return;
204
+ reported = true;
205
+ process.stderr.write(`pi-checkup crashed: ${scrubCredentials(String(e?.stack ?? e))}\n`);
206
+ process.exitCode = 2;
207
+ };
208
+ process.stdout.on("error", (e) => {
209
+ // A consumer that stopped reading is a normal exit, but not a successful one:
210
+ // `pi-checkup | head -1` over failed checks must still exit 1.
211
+ if (e?.code === "EPIPE") process.exit(process.exitCode ?? 0);
212
+ writeFailure(e);
213
+ });
214
+ process.stderr.on("error", (e) => {
215
+ if (e?.code !== "EPIPE") writeFailure(e);
216
+ });
217
+
218
+ // `process.exit()` can truncate a pipe that has not drained, and a --json report
219
+ // on a slow consumer is exactly the payload large enough to hit that. Setting
220
+ // exitCode lets Node flush and exit on its own.
221
+ main().then(
222
+ (code) => {
223
+ // A write failure already set 2, and "2 = the run itself crashed" is the
224
+ // one thing that must not be overwritten by a normal return.
225
+ if (!reported) process.exitCode = code;
226
+ },
227
+ (e) => {
228
+ // Scrubbed: an uncaught error can quote the URL or the config value it
229
+ // choked on, and a crash report is exactly where nobody looks twice.
230
+ process.stderr.write(`pi-checkup crashed: ${scrubCredentials(String(e?.stack ?? e))}\n`);
231
+ process.exitCode = 2;
232
+ },
233
+ );
package/package.json CHANGED
@@ -1,6 +1,49 @@
1
1
  {
2
2
  "name": "pi-checkup",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Health checks for your Pi setup: tool/command conflicts, broken packages, shadowed binaries, dead endpoints.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "xinghe-labs",
8
+ "engines": {
9
+ "node": ">=20"
10
+ },
11
+ "keywords": [
12
+ "pi-package",
13
+ "pi-coding-agent",
14
+ "pi",
15
+ "extension",
16
+ "healthcheck",
17
+ "diagnostics",
18
+ "doctor",
19
+ "troubleshooting"
20
+ ],
21
+ "pi": {
22
+ "extensions": [
23
+ "./src/index.js"
24
+ ]
25
+ },
26
+ "bin": {
27
+ "pi-checkup": "bin/checkup.js"
28
+ },
29
+ "files": [
30
+ "src/",
31
+ "bin/",
32
+ "scripts/",
33
+ "README.md",
34
+ "LICENSE"
35
+ ],
36
+ "scripts": {
37
+ "test": "node --test",
38
+ "lint": "node scripts/lint.js",
39
+ "checkup": "node ./bin/checkup.js"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/xinghe-labs/pi-checkup.git"
44
+ },
45
+ "bugs": {
46
+ "url": "https://github.com/xinghe-labs/pi-checkup/issues"
47
+ },
48
+ "homepage": "https://github.com/xinghe-labs/pi-checkup#readme"
49
+ }