@cruxy/cli 1.6.0 → 1.7.1
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/README.md +117 -1
- package/dist/agent/session.js +1 -5
- package/dist/components/frame.js +39 -1
- package/dist/errors/constructors.js +115 -5
- package/dist/errors/types.js +17 -0
- package/dist/lsp/index.js +1 -1
- package/dist/lsp/registry.js +28 -10
- package/dist/plan/service.js +26 -1
- package/dist/plan/submit-plan.js +11 -0
- package/dist/render/capabilities.js +9 -2
- package/dist/render/index.js +6 -1
- package/dist/tools/file/apply-patch.js +164 -70
- package/dist/tui/app.js +18 -3
- package/dist/tui/index.js +3 -2
- package/dist/tui/mode-ring.js +84 -0
- package/dist/tui/renderer.js +145 -4
- package/dist/tui/restore.js +137 -0
- package/dist/tui/supports.js +22 -0
- package/dist/tui/tool-versions.js +119 -18
- package/package.json +2 -2
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The terminal-restore backstop (Q4 track 1).
|
|
3
|
+
*
|
|
4
|
+
* `TuiRenderer.close()` hands the terminal back — it erases the frame, and from
|
|
5
|
+
* track 3 on it also leaves the alternate screen and shows the cursor again.
|
|
6
|
+
* That covers exactly one way a run ends: the orderly one. Every other way, the
|
|
7
|
+
* process dies with the shell still painted and the terminal still configured
|
|
8
|
+
* the way the TUI left it, and the user's next prompt lands in the middle of a
|
|
9
|
+
* frame that nothing will ever erase. `kill -TERM`, a hangup when the terminal
|
|
10
|
+
* window closes, an `ssh` session dropping, `kill -INT` from another pane — all
|
|
11
|
+
* of them terminate the process by DEFAULT DISPOSITION, which runs no JavaScript
|
|
12
|
+
* at all.
|
|
13
|
+
*
|
|
14
|
+
* So the restore is registered with the process rather than left to the caller:
|
|
15
|
+
*
|
|
16
|
+
* - **`SIGINT` / `SIGTERM` / `SIGHUP`** — restore, then exit with the shell's
|
|
17
|
+
* conventional 128 + signal code. `process.exit` rather than re-raising is
|
|
18
|
+
* deliberate: Node restores the original termios (`ResetStdio`, an `atexit`
|
|
19
|
+
* hook) on a normal exit but not when the default disposition fells the
|
|
20
|
+
* process, and the TUI holds stdin in RAW MODE for most of its life. Dying
|
|
21
|
+
* from the signal itself would leave a terminal with no echo and no line
|
|
22
|
+
* editing — a worse outcome than the painted frame this module exists to
|
|
23
|
+
* clean up.
|
|
24
|
+
* - **`exit`** — the last line, for the paths no signal handler sees: an
|
|
25
|
+
* uncaught throw, an explicit `process.exit` somewhere else, and the signal
|
|
26
|
+
* handlers that other modules install first ({@link ../utils/child-tree.js}
|
|
27
|
+
* registers `SIGINT` too, and whichever ran first calls `process.exit`
|
|
28
|
+
* straight away). Synchronous work only, which a write to a TTY is.
|
|
29
|
+
*
|
|
30
|
+
* Handlers are installed on the FIRST registration and removed when the last
|
|
31
|
+
* guard goes away, so a closed TUI leaves the process exactly as it found it and
|
|
32
|
+
* N renderers (a test file builds dozens) still cost one listener per event.
|
|
33
|
+
* This is the same shape as the child-process exit backstop in
|
|
34
|
+
* {@link ../utils/child-tree.js}, for the same reason.
|
|
35
|
+
*/
|
|
36
|
+
/** Signals that end a run before the normal teardown gets a turn. */
|
|
37
|
+
export const RESTORE_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
38
|
+
/**
|
|
39
|
+
* The exit code a shell reports for a process felled by each signal: 128 + n.
|
|
40
|
+
* Stated rather than inherited, because these handlers exit explicitly (see the
|
|
41
|
+
* raw-mode note above) and an exit code of 0 after a `kill -TERM` would tell
|
|
42
|
+
* every script that reads one that the run succeeded.
|
|
43
|
+
*/
|
|
44
|
+
export const SIGNAL_EXIT_CODES = {
|
|
45
|
+
SIGINT: 130,
|
|
46
|
+
SIGTERM: 143,
|
|
47
|
+
SIGHUP: 129,
|
|
48
|
+
};
|
|
49
|
+
/** Live guards, in registration order. Each entry is its own idempotent runner. */
|
|
50
|
+
const guards = new Set();
|
|
51
|
+
/** The installed listeners, or null when no guard is registered. */
|
|
52
|
+
let handlers = null;
|
|
53
|
+
/**
|
|
54
|
+
* Run every registered restore, once each. Exported because it IS what the
|
|
55
|
+
* handlers run: a test that calls this is testing the real path rather than a
|
|
56
|
+
* reimplementation of it.
|
|
57
|
+
*
|
|
58
|
+
* Iterates a copy — a restore deregisters itself as it runs — and a restore
|
|
59
|
+
* that throws does not stop the others. There is nowhere to report an error
|
|
60
|
+
* from here (the process is on its way out, and the screen is the thing that
|
|
61
|
+
* would show it), and one guard failing must not leave the next one's terminal
|
|
62
|
+
* in the alternate screen forever.
|
|
63
|
+
*/
|
|
64
|
+
export function restoreAllScreens() {
|
|
65
|
+
for (const run of [...guards])
|
|
66
|
+
run();
|
|
67
|
+
}
|
|
68
|
+
function installHandlers(host) {
|
|
69
|
+
if (handlers !== null)
|
|
70
|
+
return;
|
|
71
|
+
const entries = [
|
|
72
|
+
["exit", restoreAllScreens],
|
|
73
|
+
...RESTORE_SIGNALS.map((signal) => [
|
|
74
|
+
signal,
|
|
75
|
+
() => {
|
|
76
|
+
restoreAllScreens();
|
|
77
|
+
host.exit(SIGNAL_EXIT_CODES[signal]);
|
|
78
|
+
},
|
|
79
|
+
]),
|
|
80
|
+
];
|
|
81
|
+
for (const [event, listener] of entries)
|
|
82
|
+
host.on(event, listener);
|
|
83
|
+
handlers = { host, entries };
|
|
84
|
+
}
|
|
85
|
+
function removeHandlers() {
|
|
86
|
+
if (handlers === null)
|
|
87
|
+
return;
|
|
88
|
+
for (const [event, listener] of handlers.entries) {
|
|
89
|
+
handlers.host.off(event, listener);
|
|
90
|
+
}
|
|
91
|
+
handlers = null;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Register `restore` to run if the process is signalled or exits, and return the
|
|
95
|
+
* handle that also lets the owner run it directly.
|
|
96
|
+
*
|
|
97
|
+
* The first registration installs the handlers; the last one to go removes
|
|
98
|
+
* them. `host` is read only on that first call — it names the process the
|
|
99
|
+
* handlers are installed on, and there is only ever one.
|
|
100
|
+
*/
|
|
101
|
+
export function installScreenGuard(restore, host = process) {
|
|
102
|
+
let restored = false;
|
|
103
|
+
let live = true;
|
|
104
|
+
const deregister = () => {
|
|
105
|
+
if (!live)
|
|
106
|
+
return;
|
|
107
|
+
live = false;
|
|
108
|
+
guards.delete(run);
|
|
109
|
+
if (guards.size === 0)
|
|
110
|
+
removeHandlers();
|
|
111
|
+
};
|
|
112
|
+
const run = () => {
|
|
113
|
+
if (restored)
|
|
114
|
+
return;
|
|
115
|
+
restored = true;
|
|
116
|
+
// Deregistered BEFORE the restore runs, so a throwing restore still gives
|
|
117
|
+
// the handlers up rather than pinning them for the life of the process.
|
|
118
|
+
deregister();
|
|
119
|
+
try {
|
|
120
|
+
restore();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// See `restoreAllScreens`: there is no surface left to report on.
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
guards.add(run);
|
|
127
|
+
installHandlers(host);
|
|
128
|
+
return {
|
|
129
|
+
restoreScreen: run,
|
|
130
|
+
restored: () => restored,
|
|
131
|
+
dispose: deregister,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** Guards currently registered — for tests, the way `trackedTreeCount` is. */
|
|
135
|
+
export function screenGuardCount() {
|
|
136
|
+
return guards.size;
|
|
137
|
+
}
|
package/dist/tui/supports.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSet } from "../render/capabilities.js";
|
|
1
2
|
/**
|
|
2
3
|
* Whether the environment can host the full-viewport TUI.
|
|
3
4
|
*
|
|
@@ -18,3 +19,24 @@
|
|
|
18
19
|
export function supportsTui(caps) {
|
|
19
20
|
return caps.interactive && !caps.screenReader;
|
|
20
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether the TUI runs on the ALTERNATE SCREEN (Q4 track 3) — the terminal's
|
|
24
|
+
* second buffer, the one `less` and `vim` use.
|
|
25
|
+
*
|
|
26
|
+
* Gated on {@link supportsTui} and nothing new: a terminal that cannot host the
|
|
27
|
+
* shell has no business being switched to another buffer, and the two questions
|
|
28
|
+
* have exactly one answer between them. Adding a second capability check here
|
|
29
|
+
* would only create a state where the TUI runs but its screen management does
|
|
30
|
+
* not — which is the drift the one gate exists to prevent.
|
|
31
|
+
*
|
|
32
|
+
* `CRUXY_NO_ALT_SCREEN` opts out, following the same set-and-non-empty rule as
|
|
33
|
+
* every other cruxy flag. The opt-out exists because the alternate screen is not
|
|
34
|
+
* universally available or wanted: multiplexers and terminal emulators can be
|
|
35
|
+
* configured to refuse it, some capture-and-replay tooling reads only the normal
|
|
36
|
+
* buffer, and a user who prefers the shell to keep the frame in its scrollback
|
|
37
|
+
* should not have to stop using the TUI to get that. Opting out changes nothing
|
|
38
|
+
* else — the shell, the row addressing and the restore backstop are unaffected.
|
|
39
|
+
*/
|
|
40
|
+
export function usesAltScreen(caps, env = process.env) {
|
|
41
|
+
return supportsTui(caps) && !isSet(env.CRUXY_NO_ALT_SCREEN);
|
|
42
|
+
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// The concrete module, not `lsp/index.js`: that barrel pulls in the transport,
|
|
4
|
+
// the pool and the client, and this panel exists to keep cost off the startup
|
|
5
|
+
// path. `resolveBinary` is a leaf over `node:fs`.
|
|
6
|
+
import { resolveBinary } from "../lsp/registry.js";
|
|
2
7
|
/**
|
|
3
8
|
* Host tool versions for the rail (P4 track 5).
|
|
4
9
|
*
|
|
@@ -20,9 +25,82 @@ import { execFile } from "node:child_process";
|
|
|
20
25
|
* DAEMON and reports a stopped Docker as absent. This panel is reporting what
|
|
21
26
|
* is INSTALLED, so it asks the client (`docker --version`), which answers with
|
|
22
27
|
* the daemon down.
|
|
28
|
+
*
|
|
29
|
+
* WHICH tsc, though. The panel sits beside a repo, and the toolchain that
|
|
30
|
+
* matters there is the repo's: a project pinning TypeScript 5.4 in its
|
|
31
|
+
* devDependencies was being reported at whatever version happens to be global,
|
|
32
|
+
* or as "not found" when nothing is global at all — the ordinary case, since
|
|
33
|
+
* `pnpm`/`npx` run these out of `node_modules/.bin` and most people never
|
|
34
|
+
* install them globally. So each tool is looked for in `node_modules/.bin`,
|
|
35
|
+
* walking up from the working directory the way Node's own resolution does,
|
|
36
|
+
* before falling back to `PATH`.
|
|
37
|
+
*
|
|
38
|
+
* That lookup is {@link resolveBinary}, the LSP registry's — which also fixes a
|
|
39
|
+
* latent Windows bug this module had on the PATH half. Windows installs
|
|
40
|
+
* npm-shipped tools as `.cmd` shims, and `execFile("tsc")` there resolves
|
|
41
|
+
* nothing: `tsc.cmd` needs the `PATHEXT` sweep the registry already does by
|
|
42
|
+
* stat, and then needs `cmd.exe` to run it at all.
|
|
23
43
|
*/
|
|
24
44
|
/** Hard ceiling on one probe; a hung binary must never wedge the panel. */
|
|
25
45
|
const PROBE_TIMEOUT_MS = 5000;
|
|
46
|
+
/**
|
|
47
|
+
* Every `node_modules/.bin` from `from` up to the filesystem root, nearest
|
|
48
|
+
* first — the same walk Node's module resolution does, and the same order
|
|
49
|
+
* `pnpm`/`npx` pick a binary in. Directories that do not exist cost nothing:
|
|
50
|
+
* `resolveBinary` stats the candidate and moves on.
|
|
51
|
+
*/
|
|
52
|
+
export function binDirsFrom(from) {
|
|
53
|
+
const dirs = [];
|
|
54
|
+
let dir = path.resolve(from);
|
|
55
|
+
for (;;) {
|
|
56
|
+
dirs.push(path.join(dir, "node_modules", ".bin"));
|
|
57
|
+
const parent = path.dirname(dir);
|
|
58
|
+
if (parent === dir)
|
|
59
|
+
return dirs;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Where a probe should actually spawn `bin` from: the nearest project-local
|
|
65
|
+
* copy, else whatever `PATH` offers, else `null` — which is an ANSWER ("not
|
|
66
|
+
* installed"), reached without spawning anything.
|
|
67
|
+
*/
|
|
68
|
+
export function resolveToolBinary(bin, from) {
|
|
69
|
+
return resolveBinary(bin, binDirsFrom(from)) ?? resolveBinary(bin);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* How to invoke a resolved binary.
|
|
73
|
+
*
|
|
74
|
+
* On win32 a `.cmd`/`.bat` is a script, not an image: `CreateProcess` cannot run
|
|
75
|
+
* one, and Node has REFUSED to try since the CVE-2024-27980 fix — it throws
|
|
76
|
+
* EINVAL rather than silently handing the arguments to a shell. So a shim is run
|
|
77
|
+
* through `cmd.exe` explicitly. Everything else is spawned directly, unchanged.
|
|
78
|
+
*
|
|
79
|
+
* THE DOUBLE WRAP IS NOT A TYPO, and it is the whole reason this is a named
|
|
80
|
+
* function with its own tests. cmd.exe strips the first and last quote of the
|
|
81
|
+
* `/c` string when the string both begins and ends with one. Quoting only the
|
|
82
|
+
* path gives `cmd /c "C:\Program Files\x\t.cmd"` — begins and ends with a quote,
|
|
83
|
+
* so cmd removes both and then tries to run `C:\Program`. Appending `--version`
|
|
84
|
+
* happens to hide it (the string no longer ends in a quote), which is the worst
|
|
85
|
+
* kind of working: it breaks the day a probe takes no arguments, and every tool
|
|
86
|
+
* here is one `args: []` away from that.
|
|
87
|
+
*
|
|
88
|
+
* Wrapping the whole command line in a second pair makes `/s` the rule instead
|
|
89
|
+
* of an accident: `/s` strips exactly the outer pair and preserves the rest
|
|
90
|
+
* verbatim, so the inner quotes reach the parser intact either way. Node must
|
|
91
|
+
* not re-quote on top, hence `windowsVerbatimArguments` at the spawn.
|
|
92
|
+
*/
|
|
93
|
+
export function invocationFor(file, args, platform = process.platform, comSpec = process.env.ComSpec ?? "cmd.exe") {
|
|
94
|
+
if (platform === "win32" && /\.(cmd|bat)$/i.test(file)) {
|
|
95
|
+
const line = [`"${file}"`, ...args].join(" ");
|
|
96
|
+
return {
|
|
97
|
+
file: comSpec,
|
|
98
|
+
args: ["/d", "/s", "/c", `"${line}"`],
|
|
99
|
+
verbatim: true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { file, args: [...args], verbatim: false };
|
|
103
|
+
}
|
|
26
104
|
/**
|
|
27
105
|
* Pull a semantic version out of a tool's `--version` line.
|
|
28
106
|
*
|
|
@@ -35,24 +113,47 @@ const PROBE_TIMEOUT_MS = 5000;
|
|
|
35
113
|
export function parseVersion(output) {
|
|
36
114
|
return /\d+\.\d+\.\d+(?:[-+][\w.]+)?/.exec(output)?.[0] ?? null;
|
|
37
115
|
}
|
|
38
|
-
/**
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
116
|
+
/**
|
|
117
|
+
* The default probe: resolve, spawn, capture — and treat any failure as "not
|
|
118
|
+
* installed". Resolution comes first, so a tool that is nowhere costs a few
|
|
119
|
+
* `stat` calls rather than a process that has to fail.
|
|
120
|
+
*
|
|
121
|
+
* `from` exists so the win32 e2e can point a real probe at a real `.cmd` shim in
|
|
122
|
+
* a temp project. Left unset it reads `process.cwd()` PER CALL, never once at
|
|
123
|
+
* module load — the panel must follow the session's directory, not the one the
|
|
124
|
+
* process happened to start in.
|
|
125
|
+
*/
|
|
126
|
+
export function makeSpawnProbe(from) {
|
|
127
|
+
return (bin, args) => new Promise((resolve) => {
|
|
128
|
+
const resolved = resolveToolBinary(bin, from ?? process.cwd());
|
|
129
|
+
if (resolved === null) {
|
|
130
|
+
resolve(null);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const call = invocationFor(resolved, args);
|
|
134
|
+
try {
|
|
135
|
+
execFile(call.file, call.args, {
|
|
136
|
+
encoding: "utf8",
|
|
137
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
...(call.verbatim ? { windowsVerbatimArguments: true } : {}),
|
|
140
|
+
}, (err, stdout, stderr) => {
|
|
141
|
+
// Some tools print their version to stderr; a non-zero exit with a
|
|
142
|
+
// parseable version still tells us the tool is there.
|
|
143
|
+
const text = `${stdout ?? ""}${stderr ?? ""}`;
|
|
144
|
+
if (err && text.trim() === "")
|
|
145
|
+
resolve(null);
|
|
146
|
+
else
|
|
147
|
+
resolve(text);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// execFile can throw synchronously on a malformed binary path.
|
|
152
|
+
resolve(null);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
const spawnProbe = makeSpawnProbe();
|
|
56
157
|
/**
|
|
57
158
|
* The tools reported, in display order.
|
|
58
159
|
*
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cruxy/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "an agentic coding CLI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"undici": "^6.21.0",
|
|
37
37
|
"zod": "^3.23.8",
|
|
38
38
|
"zod-to-json-schema": "^3.23.5",
|
|
39
|
-
"@cruxy/sdk": "0.
|
|
39
|
+
"@cruxy/sdk": "0.6.0"
|
|
40
40
|
},
|
|
41
41
|
"optionalDependencies": {
|
|
42
42
|
"better-sqlite3": "^12.11.1"
|