gentle-pi 3.4.0 → 3.5.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/README.md +25 -0
- package/bin/gentle-shell.mjs +198 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +67 -0
- package/extensions/ask-user-choice.ts +70 -22
- package/extensions/ask-user-question.ts +131 -3
- package/extensions/gentle-agents.ts +33 -0
- package/lib/agents-rpc-publisher.ts +342 -0
- package/lib/agents-runner.ts +7 -2
- package/lib/gentle-shell-launcher.ts +482 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +483 -0
- package/scripts/build-runtime-modules.mjs +1 -0
- package/scripts/install-gentle-ai.mjs +14 -7
- package/scripts/install-tui-mode-setting.mjs +78 -1
- package/scripts/verify-package-files.mjs +4 -0
- package/tests/agents-rpc-publisher.test.ts +407 -0
- package/tests/agents-runner.test.ts +10 -0
- package/tests/ask-user-choice.test.ts +129 -0
- package/tests/ask-user-question.test.ts +227 -1
- package/tests/gentle-agents.test.ts +90 -0
- package/tests/gentle-shell-bin.test.ts +188 -0
- package/tests/gentle-shell-launcher.test.ts +718 -0
- package/tests/install-tui-mode-guard.test.ts +99 -0
- package/tests/install-tui-mode-setting.test.ts +39 -1
- package/tests/package-manifest.test.ts +2 -2
- package/tests/rpc-host.test.ts +77 -0
|
@@ -0,0 +1,718 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import {
|
|
7
|
+
MIN_PI_VERSION,
|
|
8
|
+
PI_SUBCOMMANDS,
|
|
9
|
+
buildPiInvocation,
|
|
10
|
+
checkPeerVersionPin,
|
|
11
|
+
checkPiVersion,
|
|
12
|
+
describeVersion,
|
|
13
|
+
helpText,
|
|
14
|
+
launcherConfigPath,
|
|
15
|
+
missingPiMessage,
|
|
16
|
+
parseLauncherArgs,
|
|
17
|
+
parseLauncherConfig,
|
|
18
|
+
planSpawn,
|
|
19
|
+
quoteForCmdExe,
|
|
20
|
+
resolveHome,
|
|
21
|
+
resolvePiRuntime,
|
|
22
|
+
settingsDeclareGentlePi,
|
|
23
|
+
type PackageJsonPeerShape,
|
|
24
|
+
type ParsedLauncherArgs,
|
|
25
|
+
type ResolvedHome,
|
|
26
|
+
} from "../lib/gentle-shell-launcher.ts";
|
|
27
|
+
|
|
28
|
+
const packageRoot = join(fileURLToPath(import.meta.url), "..", "..");
|
|
29
|
+
|
|
30
|
+
// --- parseLauncherArgs -------------------------------------------------
|
|
31
|
+
|
|
32
|
+
test("parseLauncherArgs returns all-false defaults for an empty argv", () => {
|
|
33
|
+
const parsed = parseLauncherArgs([]);
|
|
34
|
+
assert.deepEqual(parsed, {
|
|
35
|
+
link: false,
|
|
36
|
+
isolated: false,
|
|
37
|
+
home: undefined,
|
|
38
|
+
help: false,
|
|
39
|
+
version: false,
|
|
40
|
+
command: undefined,
|
|
41
|
+
commandArgs: [],
|
|
42
|
+
passthrough: [],
|
|
43
|
+
piSubcommand: undefined,
|
|
44
|
+
error: undefined,
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("parseLauncherArgs sets link on --link", () => {
|
|
49
|
+
assert.equal(parseLauncherArgs(["--link"]).link, true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("parseLauncherArgs sets isolated on --isolated", () => {
|
|
53
|
+
assert.equal(parseLauncherArgs(["--isolated"]).isolated, true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("parseLauncherArgs captures a --home value from the next argument", () => {
|
|
57
|
+
assert.equal(parseLauncherArgs(["--home", "/custom/path"]).home, "/custom/path");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("parseLauncherArgs captures a --home=<path> value", () => {
|
|
61
|
+
assert.equal(parseLauncherArgs(["--home=/custom/path"]).home, "/custom/path");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("parseLauncherArgs reports an error when --home has no value", () => {
|
|
65
|
+
const parsed = parseLauncherArgs(["--home"]);
|
|
66
|
+
assert.equal(parsed.home, undefined);
|
|
67
|
+
assert.match(parsed.error ?? "", /--home/);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("parseLauncherArgs reports an error when --home=<empty> has no value", () => {
|
|
71
|
+
const parsed = parseLauncherArgs(["--home="]);
|
|
72
|
+
assert.equal(parsed.home, undefined);
|
|
73
|
+
assert.match(parsed.error ?? "", /--home/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("parseLauncherArgs reports an error when a bare --home value is empty", () => {
|
|
77
|
+
const parsed = parseLauncherArgs(["--home", ""]);
|
|
78
|
+
assert.equal(parsed.home, undefined);
|
|
79
|
+
assert.match(parsed.error ?? "", /--home/);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("parseLauncherArgs sets help on --help and -h", () => {
|
|
83
|
+
assert.equal(parseLauncherArgs(["--help"]).help, true);
|
|
84
|
+
assert.equal(parseLauncherArgs(["-h"]).help, true);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("parseLauncherArgs sets version on --version", () => {
|
|
88
|
+
assert.equal(parseLauncherArgs(["--version"]).version, true);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("parseLauncherArgs stops launcher parsing at -- and forwards the rest", () => {
|
|
92
|
+
const parsed = parseLauncherArgs(["--isolated", "--", "--link", "--help"]);
|
|
93
|
+
assert.equal(parsed.isolated, true);
|
|
94
|
+
assert.equal(parsed.link, false);
|
|
95
|
+
assert.equal(parsed.help, false);
|
|
96
|
+
assert.deepEqual(parsed.passthrough, ["--link", "--help"]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("parseLauncherArgs forwards unrecognised arguments as passthrough, in order", () => {
|
|
100
|
+
const parsed = parseLauncherArgs(["--mode", "rpc", "-p", "hi", "--resume"]);
|
|
101
|
+
assert.deepEqual(parsed.passthrough, ["--mode", "rpc", "-p", "hi", "--resume"]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("parseLauncherArgs mixes launcher flags and passthrough while keeping passthrough order", () => {
|
|
105
|
+
const parsed = parseLauncherArgs(["--isolated", "--mode", "rpc", "-p", "hi"]);
|
|
106
|
+
assert.equal(parsed.isolated, true);
|
|
107
|
+
assert.deepEqual(parsed.passthrough, ["--mode", "rpc", "-p", "hi"]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("parseLauncherArgs recognises the home subcommand as argv[0] and captures the rest as commandArgs", () => {
|
|
111
|
+
const parsed = parseLauncherArgs(["home", "link"]);
|
|
112
|
+
assert.equal(parsed.command, "home");
|
|
113
|
+
assert.deepEqual(parsed.commandArgs, ["link"]);
|
|
114
|
+
assert.deepEqual(parsed.passthrough, []);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("parseLauncherArgs treats home as a plain passthrough token when it is not argv[0]", () => {
|
|
118
|
+
const parsed = parseLauncherArgs(["--isolated", "home"]);
|
|
119
|
+
assert.equal(parsed.command, undefined);
|
|
120
|
+
assert.deepEqual(parsed.passthrough, ["home"]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// --- pi subcommand passthrough (install/remove/uninstall/update/list/config/auth) ---
|
|
124
|
+
|
|
125
|
+
test("parseLauncherArgs recognises install as a pi subcommand and keeps it in passthrough", () => {
|
|
126
|
+
const parsed = parseLauncherArgs(["install", "npm:x"]);
|
|
127
|
+
assert.equal(parsed.piSubcommand, "install");
|
|
128
|
+
assert.deepEqual(parsed.passthrough, ["install", "npm:x"]);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("parseLauncherArgs recognises every pi subcommand", () => {
|
|
132
|
+
for (const subcommand of PI_SUBCOMMANDS) {
|
|
133
|
+
const parsed = parseLauncherArgs([subcommand]);
|
|
134
|
+
assert.equal(parsed.piSubcommand, subcommand);
|
|
135
|
+
assert.deepEqual(parsed.passthrough, [subcommand]);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("parseLauncherArgs keeps piSubcommand when a launcher flag precedes it", () => {
|
|
140
|
+
const parsed = parseLauncherArgs(["--link", "install", "npm:x"]);
|
|
141
|
+
assert.equal(parsed.link, true);
|
|
142
|
+
assert.equal(parsed.piSubcommand, "install");
|
|
143
|
+
assert.deepEqual(parsed.passthrough, ["install", "npm:x"]);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("parseLauncherArgs does not set piSubcommand for the home launcher subcommand", () => {
|
|
147
|
+
const parsed = parseLauncherArgs(["home", "link"]);
|
|
148
|
+
assert.equal(parsed.command, "home");
|
|
149
|
+
assert.equal(parsed.piSubcommand, undefined);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("parseLauncherArgs does not treat an arbitrary prompt word as a pi subcommand", () => {
|
|
153
|
+
const parsed = parseLauncherArgs(["hello"]);
|
|
154
|
+
assert.equal(parsed.piSubcommand, undefined);
|
|
155
|
+
assert.deepEqual(parsed.passthrough, ["hello"]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("parseLauncherArgs only recognises the first passthrough token as a pi subcommand", () => {
|
|
159
|
+
const parsed = parseLauncherArgs(["hello", "install"]);
|
|
160
|
+
assert.equal(parsed.piSubcommand, undefined);
|
|
161
|
+
assert.deepEqual(parsed.passthrough, ["hello", "install"]);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("parseLauncherArgs recognises a pi subcommand as the first token after --", () => {
|
|
165
|
+
const parsed = parseLauncherArgs(["--", "install", "npm:x"]);
|
|
166
|
+
assert.equal(parsed.piSubcommand, "install");
|
|
167
|
+
assert.deepEqual(parsed.passthrough, ["install", "npm:x"]);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("parseLauncherArgs errors when --link is combined with --isolated", () => {
|
|
171
|
+
const parsed = parseLauncherArgs(["--link", "--isolated"]);
|
|
172
|
+
assert.match(parsed.error ?? "", /--link/);
|
|
173
|
+
assert.match(parsed.error ?? "", /--isolated/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("parseLauncherArgs errors when --link is combined with --home", () => {
|
|
177
|
+
const parsed = parseLauncherArgs(["--link", "--home", "/custom"]);
|
|
178
|
+
assert.match(parsed.error ?? "", /--link/);
|
|
179
|
+
assert.match(parsed.error ?? "", /--home/);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("parseLauncherArgs errors when --home is combined with --link regardless of order", () => {
|
|
183
|
+
const parsed = parseLauncherArgs(["--home", "/custom", "--link"]);
|
|
184
|
+
assert.match(parsed.error ?? "", /--link/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("parseLauncherArgs errors when --isolated is combined with --home", () => {
|
|
188
|
+
const parsed = parseLauncherArgs(["--isolated", "--home", "/custom"]);
|
|
189
|
+
assert.match(parsed.error ?? "", /--isolated/);
|
|
190
|
+
assert.match(parsed.error ?? "", /--home/);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("parseLauncherArgs errors when --home is combined with --isolated regardless of order", () => {
|
|
194
|
+
const parsed = parseLauncherArgs(["--home", "/custom", "--isolated"]);
|
|
195
|
+
assert.match(parsed.error ?? "", /--isolated/);
|
|
196
|
+
assert.match(parsed.error ?? "", /--home/);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// --- resolveHome ---------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
function args(overrides: Partial<ParsedLauncherArgs> = {}): ParsedLauncherArgs {
|
|
202
|
+
return {
|
|
203
|
+
link: false,
|
|
204
|
+
isolated: false,
|
|
205
|
+
home: undefined,
|
|
206
|
+
help: false,
|
|
207
|
+
version: false,
|
|
208
|
+
command: undefined,
|
|
209
|
+
commandArgs: [],
|
|
210
|
+
passthrough: [],
|
|
211
|
+
error: undefined,
|
|
212
|
+
...overrides,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
test("resolveHome honours --link and reads PI_CODING_AGENT_DIR", () => {
|
|
217
|
+
const resolved = resolveHome({
|
|
218
|
+
args: args({ link: true }),
|
|
219
|
+
env: { PI_CODING_AGENT_DIR: "/pi/agent" },
|
|
220
|
+
homedir: "/home/alan",
|
|
221
|
+
config: undefined,
|
|
222
|
+
});
|
|
223
|
+
assert.deepEqual(resolved, { mode: "link", dir: "/pi/agent", source: "flag" });
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("resolveHome falls back to <homedir>/.pi/agent for --link with no override", () => {
|
|
227
|
+
const resolved = resolveHome({ args: args({ link: true }), env: {}, homedir: "/home/alan", config: undefined });
|
|
228
|
+
assert.deepEqual(resolved, { mode: "link", dir: join("/home/alan", ".pi", "agent"), source: "flag" });
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("resolveHome honours --isolated and reads GENTLE_SHELL_HOME", () => {
|
|
232
|
+
const resolved = resolveHome({
|
|
233
|
+
args: args({ isolated: true }),
|
|
234
|
+
env: { GENTLE_SHELL_HOME: "/custom/isolated" },
|
|
235
|
+
homedir: "/home/alan",
|
|
236
|
+
config: undefined,
|
|
237
|
+
});
|
|
238
|
+
assert.deepEqual(resolved, { mode: "isolated", dir: "/custom/isolated", source: "flag" });
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("resolveHome falls back to <homedir>/.gentle-shell/agent for --isolated with no override", () => {
|
|
242
|
+
const resolved = resolveHome({ args: args({ isolated: true }), env: {}, homedir: "/home/alan", config: undefined });
|
|
243
|
+
assert.deepEqual(resolved, { mode: "isolated", dir: join("/home/alan", ".gentle-shell", "agent"), source: "flag" });
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("resolveHome uses the --home value verbatim", () => {
|
|
247
|
+
const resolved = resolveHome({ args: args({ home: "/explicit/path" }), env: {}, homedir: "/home/alan", config: undefined });
|
|
248
|
+
assert.deepEqual(resolved, { mode: "path", dir: "/explicit/path", source: "flag" });
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("resolveHome falls back to a persisted link config", () => {
|
|
252
|
+
const resolved = resolveHome({ args: args(), env: {}, homedir: "/home/alan", config: { mode: "link" } });
|
|
253
|
+
assert.deepEqual(resolved, { mode: "link", dir: join("/home/alan", ".pi", "agent"), source: "config" });
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("resolveHome falls back to a persisted isolated config", () => {
|
|
257
|
+
const resolved = resolveHome({ args: args(), env: {}, homedir: "/home/alan", config: { mode: "isolated" } });
|
|
258
|
+
assert.deepEqual(resolved, { mode: "isolated", dir: join("/home/alan", ".gentle-shell", "agent"), source: "config" });
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("resolveHome falls back to a persisted path config", () => {
|
|
262
|
+
const resolved = resolveHome({ args: args(), env: {}, homedir: "/home/alan", config: { mode: "path", dir: "/persisted/path" } });
|
|
263
|
+
assert.deepEqual(resolved, { mode: "path", dir: "/persisted/path", source: "config" });
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("resolveHome defaults to isolated when neither a flag nor a config is present", () => {
|
|
267
|
+
const resolved = resolveHome({ args: args(), env: {}, homedir: "/home/alan", config: undefined });
|
|
268
|
+
assert.deepEqual(resolved, { mode: "isolated", dir: join("/home/alan", ".gentle-shell", "agent"), source: "default" });
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test("resolveHome lets a flag override a persisted config", () => {
|
|
272
|
+
const resolved = resolveHome({ args: args({ link: true }), env: {}, homedir: "/home/alan", config: { mode: "isolated" } });
|
|
273
|
+
assert.equal(resolved.mode, "link");
|
|
274
|
+
assert.equal(resolved.source, "flag");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// --- launcherConfigPath / parseLauncherConfig -----------------------------
|
|
278
|
+
|
|
279
|
+
test("launcherConfigPath points at <homedir>/.gentle-shell/config.json", () => {
|
|
280
|
+
assert.equal(launcherConfigPath("/home/alan"), join("/home/alan", ".gentle-shell", "config.json"));
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("parseLauncherConfig accepts a link config", () => {
|
|
284
|
+
assert.deepEqual(parseLauncherConfig('{"home":"link"}'), { mode: "link" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("parseLauncherConfig accepts an isolated config", () => {
|
|
288
|
+
assert.deepEqual(parseLauncherConfig('{"home":"isolated"}'), { mode: "isolated" });
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("parseLauncherConfig accepts a path config", () => {
|
|
292
|
+
assert.deepEqual(parseLauncherConfig('{"home":"/custom/path"}'), { mode: "path", dir: "/custom/path" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("parseLauncherConfig treats any non-link/isolated string as a path, including a near-miss like 'linked'", () => {
|
|
296
|
+
assert.deepEqual(parseLauncherConfig('{"home":"linked"}'), { mode: "path", dir: "linked" });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test("parseLauncherConfig tolerates invalid JSON", () => {
|
|
300
|
+
assert.equal(parseLauncherConfig("not json"), undefined);
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test("parseLauncherConfig tolerates a missing home field", () => {
|
|
304
|
+
assert.equal(parseLauncherConfig("{}"), undefined);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("parseLauncherConfig tolerates a non-object document", () => {
|
|
308
|
+
assert.equal(parseLauncherConfig("[]"), undefined);
|
|
309
|
+
assert.equal(parseLauncherConfig('"link"'), undefined);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("parseLauncherConfig tolerates a non-string home value", () => {
|
|
313
|
+
assert.equal(parseLauncherConfig('{"home":1}'), undefined);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test("parseLauncherConfig tolerates an empty home value", () => {
|
|
317
|
+
assert.equal(parseLauncherConfig('{"home":""}'), undefined);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
// --- resolvePiRuntime ------------------------------------------------------
|
|
321
|
+
|
|
322
|
+
test("resolvePiRuntime prefers GENTLE_SHELL_PI over every other source", () => {
|
|
323
|
+
const runtime = resolvePiRuntime({
|
|
324
|
+
env: { GENTLE_SHELL_PI: "/opt/pi/pi" },
|
|
325
|
+
resolveBundledCli: () => "/bundled/cli.js",
|
|
326
|
+
findOnPath: () => "/usr/bin/pi",
|
|
327
|
+
nodeExecPath: "/usr/bin/node",
|
|
328
|
+
});
|
|
329
|
+
assert.deepEqual(runtime, { kind: "env", command: "/opt/pi/pi", args: [] });
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test("resolvePiRuntime falls back to the bundled CLI run with the current node", () => {
|
|
333
|
+
const runtime = resolvePiRuntime({
|
|
334
|
+
env: {},
|
|
335
|
+
resolveBundledCli: () => "/bundled/cli.js",
|
|
336
|
+
findOnPath: () => "/usr/bin/pi",
|
|
337
|
+
nodeExecPath: "/usr/bin/node",
|
|
338
|
+
});
|
|
339
|
+
assert.deepEqual(runtime, { kind: "bundled", command: "/usr/bin/node", args: ["/bundled/cli.js"] });
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("resolvePiRuntime falls back to pi on PATH when nothing else resolves", () => {
|
|
343
|
+
const runtime = resolvePiRuntime({
|
|
344
|
+
env: {},
|
|
345
|
+
resolveBundledCli: () => undefined,
|
|
346
|
+
findOnPath: (name) => (name === "pi" ? "/usr/bin/pi" : undefined),
|
|
347
|
+
nodeExecPath: "/usr/bin/node",
|
|
348
|
+
});
|
|
349
|
+
assert.deepEqual(runtime, { kind: "path", command: "/usr/bin/pi", args: [] });
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test("resolvePiRuntime returns undefined when no source resolves", () => {
|
|
353
|
+
const runtime = resolvePiRuntime({
|
|
354
|
+
env: {},
|
|
355
|
+
resolveBundledCli: () => undefined,
|
|
356
|
+
findOnPath: () => undefined,
|
|
357
|
+
nodeExecPath: "/usr/bin/node",
|
|
358
|
+
});
|
|
359
|
+
assert.equal(runtime, undefined);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
test("resolvePiRuntime treats an empty GENTLE_SHELL_PI as unset", () => {
|
|
363
|
+
const runtime = resolvePiRuntime({
|
|
364
|
+
env: { GENTLE_SHELL_PI: "" },
|
|
365
|
+
resolveBundledCli: () => "/bundled/cli.js",
|
|
366
|
+
findOnPath: () => undefined,
|
|
367
|
+
nodeExecPath: "/usr/bin/node",
|
|
368
|
+
});
|
|
369
|
+
assert.deepEqual(runtime, { kind: "bundled", command: "/usr/bin/node", args: ["/bundled/cli.js"] });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("missingPiMessage names the three resolution options", () => {
|
|
373
|
+
const message = missingPiMessage();
|
|
374
|
+
assert.match(message, /GENTLE_SHELL_PI/);
|
|
375
|
+
assert.match(message, /@earendil-works\/pi-coding-agent/);
|
|
376
|
+
assert.match(message, /PATH/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
// --- checkPiVersion ---------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
test("MIN_PI_VERSION matches the pinned peer dependency, so the two cannot drift", () => {
|
|
382
|
+
const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as PackageJsonPeerShape;
|
|
383
|
+
const result = checkPeerVersionPin(packageJson, "@earendil-works/pi-coding-agent", MIN_PI_VERSION);
|
|
384
|
+
if (result.ok) return;
|
|
385
|
+
assert.equal(result.ok, false);
|
|
386
|
+
assert.fail(result.message);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// --- checkPeerVersionPin ------------------------------------------------------
|
|
390
|
+
// The drift-guard test above must fail with a clear assertion message, not a
|
|
391
|
+
// raw TypeError from indexing an undefined peerDependencies block or entry.
|
|
392
|
+
// These tests exercise that failure path directly against synthetic input,
|
|
393
|
+
// since a real, well-formed package.json cannot exercise it.
|
|
394
|
+
|
|
395
|
+
test("checkPeerVersionPin reports a missing peerDependencies block clearly", () => {
|
|
396
|
+
const result = checkPeerVersionPin({}, "@earendil-works/pi-coding-agent", MIN_PI_VERSION);
|
|
397
|
+
assert.equal(result.ok, false);
|
|
398
|
+
if (result.ok) throw new Error("expected a failing result");
|
|
399
|
+
assert.match(result.message, /peerDependencies/);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("checkPeerVersionPin reports a missing peer entry clearly", () => {
|
|
403
|
+
const result = checkPeerVersionPin({ peerDependencies: {} }, "@earendil-works/pi-coding-agent", MIN_PI_VERSION);
|
|
404
|
+
assert.equal(result.ok, false);
|
|
405
|
+
if (result.ok) throw new Error("expected a failing result");
|
|
406
|
+
assert.match(result.message, /@earendil-works\/pi-coding-agent/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("checkPeerVersionPin reports a malformed peer range clearly", () => {
|
|
410
|
+
const result = checkPeerVersionPin(
|
|
411
|
+
{ peerDependencies: { "@earendil-works/pi-coding-agent": "^0.85.1" } },
|
|
412
|
+
"@earendil-works/pi-coding-agent",
|
|
413
|
+
MIN_PI_VERSION,
|
|
414
|
+
);
|
|
415
|
+
assert.equal(result.ok, false);
|
|
416
|
+
if (result.ok) throw new Error("expected a failing result");
|
|
417
|
+
assert.match(result.message, />=x\.y\.z/);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
test("checkPeerVersionPin reports a mismatched minimum clearly", () => {
|
|
421
|
+
const result = checkPeerVersionPin(
|
|
422
|
+
{ peerDependencies: { "@earendil-works/pi-coding-agent": ">=0.80.0" } },
|
|
423
|
+
"@earendil-works/pi-coding-agent",
|
|
424
|
+
MIN_PI_VERSION,
|
|
425
|
+
);
|
|
426
|
+
assert.equal(result.ok, false);
|
|
427
|
+
if (result.ok) throw new Error("expected a failing result");
|
|
428
|
+
assert.match(result.message, /0\.80\.0/);
|
|
429
|
+
assert.match(result.message, new RegExp(MIN_PI_VERSION.replace(/\./g, "\\.")));
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("checkPeerVersionPin passes for a matching pin", () => {
|
|
433
|
+
const result = checkPeerVersionPin({ peerDependencies: { "@earendil-works/pi-coding-agent": ">=0.85.1" } }, "@earendil-works/pi-coding-agent", "0.85.1");
|
|
434
|
+
assert.deepEqual(result, { ok: true, pinned: ">=0.85.1" });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("checkPiVersion accepts a version equal to the minimum", () => {
|
|
438
|
+
assert.deepEqual(checkPiVersion("0.85.1"), { ok: true, version: "0.85.1" });
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("checkPiVersion accepts a version above the minimum", () => {
|
|
442
|
+
assert.deepEqual(checkPiVersion("0.86.0"), { ok: true, version: "0.86.0" });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
test("checkPiVersion accepts a v-prefixed version", () => {
|
|
446
|
+
assert.deepEqual(checkPiVersion("v0.85.1"), { ok: true, version: "0.85.1" });
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
test("checkPiVersion accepts a prerelease suffix at the minimum", () => {
|
|
450
|
+
assert.deepEqual(checkPiVersion("pi version 0.85.1-rc.2"), { ok: true, version: "0.85.1" });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
test("checkPiVersion rejects a version below the minimum and names both versions", () => {
|
|
454
|
+
const result = checkPiVersion("0.85.0");
|
|
455
|
+
assert.equal(result.ok, false);
|
|
456
|
+
if (result.ok) throw new Error("expected a failing result");
|
|
457
|
+
assert.equal(result.version, "0.85.0");
|
|
458
|
+
assert.match(result.message, /0\.85\.0/);
|
|
459
|
+
assert.match(result.message, /0\.85\.1/);
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("checkPiVersion rejects a prerelease below the minimum", () => {
|
|
463
|
+
const result = checkPiVersion("0.85.0-beta.1");
|
|
464
|
+
assert.equal(result.ok, false);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
test("checkPiVersion reports unparsable output with the raw text and the minimum", () => {
|
|
468
|
+
const result = checkPiVersion(" not a version ");
|
|
469
|
+
assert.equal(result.ok, false);
|
|
470
|
+
if (result.ok) throw new Error("expected a failing result");
|
|
471
|
+
assert.equal(result.version, undefined);
|
|
472
|
+
assert.match(result.message, /not a version/);
|
|
473
|
+
assert.match(result.message, /0\.85\.1/);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
test("checkPiVersion accepts a custom minimum", () => {
|
|
477
|
+
assert.equal(checkPiVersion("1.0.0", "1.1.0").ok, false);
|
|
478
|
+
assert.equal(checkPiVersion("1.1.0", "1.1.0").ok, true);
|
|
479
|
+
assert.equal(checkPiVersion("1.2.0", "1.1.0").ok, true);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
// --- settingsDeclareGentlePi ------------------------------------------------
|
|
483
|
+
|
|
484
|
+
test("settingsDeclareGentlePi is false when settings text is undefined", () => {
|
|
485
|
+
assert.equal(settingsDeclareGentlePi(undefined), false);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
test("settingsDeclareGentlePi is false for invalid JSON", () => {
|
|
489
|
+
assert.equal(settingsDeclareGentlePi("not json"), false);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("settingsDeclareGentlePi is false when packages is absent", () => {
|
|
493
|
+
assert.equal(settingsDeclareGentlePi("{}"), false);
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
test("settingsDeclareGentlePi detects a bare npm:gentle-pi string entry", () => {
|
|
497
|
+
assert.equal(settingsDeclareGentlePi('{"packages":["npm:gentle-pi"]}'), true);
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("settingsDeclareGentlePi detects a versioned npm:gentle-pi string entry", () => {
|
|
501
|
+
assert.equal(settingsDeclareGentlePi('{"packages":["npm:gentle-pi@3.3.0"]}'), true);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("settingsDeclareGentlePi detects a bare npm:gentle-pi object source entry", () => {
|
|
505
|
+
assert.equal(settingsDeclareGentlePi('{"packages":[{"source":"npm:gentle-pi"}]}'), true);
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
test("settingsDeclareGentlePi detects a versioned npm:gentle-pi object source entry", () => {
|
|
509
|
+
assert.equal(settingsDeclareGentlePi('{"packages":[{"source":"npm:gentle-pi@3.3.0"}]}'), true);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test("settingsDeclareGentlePi is false when packages lists unrelated entries", () => {
|
|
513
|
+
assert.equal(settingsDeclareGentlePi('{"packages":["npm:some-other-package"]}'), false);
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
// --- buildPiInvocation -------------------------------------------------------
|
|
517
|
+
|
|
518
|
+
const linkHome: ResolvedHome = { mode: "link", dir: "/pi/agent", source: "flag" };
|
|
519
|
+
const isolatedHomeResolved: ResolvedHome = { mode: "isolated", dir: "/gentle-shell/agent", source: "default" };
|
|
520
|
+
|
|
521
|
+
test("buildPiInvocation injects the launcher env into baseEnv", () => {
|
|
522
|
+
const built = buildPiInvocation({
|
|
523
|
+
runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
|
|
524
|
+
home: linkHome,
|
|
525
|
+
packageRoot: "/pkg",
|
|
526
|
+
settingsDeclareGentlePi: true,
|
|
527
|
+
passthrough: [],
|
|
528
|
+
baseEnv: { PATH: "/usr/bin" },
|
|
529
|
+
});
|
|
530
|
+
assert.deepEqual(built.env, { PATH: "/usr/bin", PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/pi/agent" });
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
test("buildPiInvocation skips the gentle-pi injection flags when settings already declare the package", () => {
|
|
534
|
+
const built = buildPiInvocation({
|
|
535
|
+
runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
|
|
536
|
+
home: linkHome,
|
|
537
|
+
packageRoot: "/pkg",
|
|
538
|
+
settingsDeclareGentlePi: true,
|
|
539
|
+
passthrough: ["--mode", "rpc"],
|
|
540
|
+
baseEnv: {},
|
|
541
|
+
});
|
|
542
|
+
assert.deepEqual(built.command, "/usr/bin/pi");
|
|
543
|
+
assert.deepEqual(built.args, ["--mode", "rpc"]);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
test("buildPiInvocation adds the gentle-pi injection flags when settings do not declare the package", () => {
|
|
547
|
+
const built = buildPiInvocation({
|
|
548
|
+
runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
|
|
549
|
+
home: isolatedHomeResolved,
|
|
550
|
+
packageRoot: "/pkg",
|
|
551
|
+
settingsDeclareGentlePi: false,
|
|
552
|
+
passthrough: ["--mode", "rpc"],
|
|
553
|
+
baseEnv: {},
|
|
554
|
+
});
|
|
555
|
+
assert.deepEqual(built.args, [
|
|
556
|
+
"-e",
|
|
557
|
+
"/pkg",
|
|
558
|
+
"--theme",
|
|
559
|
+
join("/pkg", "themes"),
|
|
560
|
+
"--skill",
|
|
561
|
+
join("/pkg", "skills"),
|
|
562
|
+
"--prompt-template",
|
|
563
|
+
join("/pkg", "prompts"),
|
|
564
|
+
"--mode",
|
|
565
|
+
"rpc",
|
|
566
|
+
]);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("buildPiInvocation emits only runtime args and passthrough when a pi subcommand is set, skipping injection", () => {
|
|
570
|
+
const built = buildPiInvocation({
|
|
571
|
+
runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
|
|
572
|
+
home: isolatedHomeResolved,
|
|
573
|
+
packageRoot: "/pkg",
|
|
574
|
+
settingsDeclareGentlePi: false,
|
|
575
|
+
passthrough: ["install", "npm:x"],
|
|
576
|
+
piSubcommand: "install",
|
|
577
|
+
baseEnv: {},
|
|
578
|
+
});
|
|
579
|
+
assert.deepEqual(built.args, ["install", "npm:x"]);
|
|
580
|
+
assert.equal(built.command, "/usr/bin/pi");
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
test("buildPiInvocation still injects the launcher env for a pi subcommand", () => {
|
|
584
|
+
const built = buildPiInvocation({
|
|
585
|
+
runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
|
|
586
|
+
home: isolatedHomeResolved,
|
|
587
|
+
packageRoot: "/pkg",
|
|
588
|
+
settingsDeclareGentlePi: false,
|
|
589
|
+
passthrough: ["list"],
|
|
590
|
+
piSubcommand: "list",
|
|
591
|
+
baseEnv: { PATH: "/usr/bin" },
|
|
592
|
+
});
|
|
593
|
+
assert.deepEqual(built.env, {
|
|
594
|
+
PATH: "/usr/bin",
|
|
595
|
+
PI_CODING_AGENT_DIR: "/gentle-shell/agent",
|
|
596
|
+
GENTLE_PI_AGENT_HOME: "/gentle-shell/agent",
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("buildPiInvocation in link mode with a pi subcommand is exactly pi <subcommand> against the user's own agent dir", () => {
|
|
601
|
+
const built = buildPiInvocation({
|
|
602
|
+
runtime: { kind: "path", command: "/usr/bin/pi", args: [] },
|
|
603
|
+
home: linkHome,
|
|
604
|
+
packageRoot: "/pkg",
|
|
605
|
+
settingsDeclareGentlePi: false,
|
|
606
|
+
passthrough: ["auth", "status"],
|
|
607
|
+
piSubcommand: "auth",
|
|
608
|
+
baseEnv: {},
|
|
609
|
+
});
|
|
610
|
+
assert.equal(built.command, "/usr/bin/pi");
|
|
611
|
+
assert.deepEqual(built.args, ["auth", "status"]);
|
|
612
|
+
assert.deepEqual(built.env, { PI_CODING_AGENT_DIR: "/pi/agent", GENTLE_PI_AGENT_HOME: "/pi/agent" });
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("buildPiInvocation keeps the runtime's own args ahead of the injection and passthrough", () => {
|
|
616
|
+
const built = buildPiInvocation({
|
|
617
|
+
runtime: { kind: "bundled", command: "/usr/bin/node", args: ["/bundled/cli.js"] },
|
|
618
|
+
home: isolatedHomeResolved,
|
|
619
|
+
packageRoot: "/pkg",
|
|
620
|
+
settingsDeclareGentlePi: false,
|
|
621
|
+
passthrough: [],
|
|
622
|
+
baseEnv: {},
|
|
623
|
+
});
|
|
624
|
+
assert.equal(built.args[0], "/bundled/cli.js");
|
|
625
|
+
assert.equal(built.command, "/usr/bin/node");
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
// --- planSpawn / quoteForCmdExe ----------------------------------------------
|
|
629
|
+
//
|
|
630
|
+
// R3-001: on win32, `findOnPath` in bin/gentle-shell.mjs can resolve a PATHEXT
|
|
631
|
+
// candidate such as a .CMD or .BAT shim (exactly how an npm-installed `pi`
|
|
632
|
+
// lands on PATH). Node refuses to spawn a batch file directly without
|
|
633
|
+
// `shell: true` (EINVAL), so both the version probe and the real launch must
|
|
634
|
+
// route a batch shim through cmd.exe.
|
|
635
|
+
|
|
636
|
+
test("planSpawn runs a win32 .CMD shim through the shell as a single quoted command line", () => {
|
|
637
|
+
const plan = planSpawn({
|
|
638
|
+
command: "C:\\Users\\x\\AppData\\Roaming\\npm\\pi.CMD",
|
|
639
|
+
args: [],
|
|
640
|
+
platform: "win32",
|
|
641
|
+
});
|
|
642
|
+
assert.equal(plan.shell, true);
|
|
643
|
+
assert.equal(plan.args.length, 0);
|
|
644
|
+
// No spaces in the path, so quoting is optional; only the exact text must be present.
|
|
645
|
+
assert.equal(plan.command, "C:\\Users\\x\\AppData\\Roaming\\npm\\pi.CMD");
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("planSpawn quotes a win32 .bat shim and its args that contain spaces", () => {
|
|
649
|
+
const plan = planSpawn({
|
|
650
|
+
command: "C:\\Program Files\\pi\\pi.bat",
|
|
651
|
+
args: ["--mode", "rpc", "hello world"],
|
|
652
|
+
platform: "win32",
|
|
653
|
+
});
|
|
654
|
+
assert.equal(plan.shell, true);
|
|
655
|
+
assert.deepEqual(plan.args, []);
|
|
656
|
+
assert.equal(plan.command, '"C:\\Program Files\\pi\\pi.bat" --mode rpc "hello world"');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("planSpawn leaves a win32 .exe or extension-less command unchanged", () => {
|
|
660
|
+
const exe = planSpawn({ command: "C:\\pi\\pi.exe", args: ["--version"], platform: "win32" });
|
|
661
|
+
assert.deepEqual(exe, { command: "C:\\pi\\pi.exe", args: ["--version"], shell: false });
|
|
662
|
+
|
|
663
|
+
const bare = planSpawn({ command: "pi", args: ["--version"], platform: "win32" });
|
|
664
|
+
assert.deepEqual(bare, { command: "pi", args: ["--version"], shell: false });
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
test("planSpawn never enables the shell on posix, even for a .cmd-named command", () => {
|
|
668
|
+
for (const platform of ["darwin", "linux"] as const) {
|
|
669
|
+
const plan = planSpawn({ command: "/usr/local/bin/pi.cmd", args: ["--version"], platform });
|
|
670
|
+
assert.deepEqual(plan, { command: "/usr/local/bin/pi.cmd", args: ["--version"], shell: false });
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
test("quoteForCmdExe leaves a plain token untouched", () => {
|
|
675
|
+
assert.equal(quoteForCmdExe("pi"), "pi");
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
test("quoteForCmdExe quotes a token with a space and escapes an inner double quote", () => {
|
|
679
|
+
assert.equal(quoteForCmdExe('say "hi" now'), '"say \\"hi\\" now"');
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
test("quoteForCmdExe quotes an empty token", () => {
|
|
683
|
+
assert.equal(quoteForCmdExe(""), '""');
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
// --- describeVersion / helpText ----------------------------------------------
|
|
687
|
+
|
|
688
|
+
test("describeVersion formats the three-line report with a found pi version", () => {
|
|
689
|
+
const text = describeVersion({ gentlePiVersion: "3.3.0", piVersion: "0.85.1", home: linkHome });
|
|
690
|
+
assert.equal(text, "gentle-shell 3.3.0\npi 0.85.1\nhome link /pi/agent");
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
test("describeVersion reports pi as not found when no pi version is available", () => {
|
|
694
|
+
const text = describeVersion({ gentlePiVersion: "3.3.0", piVersion: undefined, home: isolatedHomeResolved });
|
|
695
|
+
assert.equal(text, "gentle-shell 3.3.0\npi not found\nhome isolated /gentle-shell/agent");
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
test("helpText documents the launcher flags, the home subcommand, the env vars, and passthrough forwarding", () => {
|
|
699
|
+
const text = helpText();
|
|
700
|
+
assert.match(text, /--link/);
|
|
701
|
+
assert.match(text, /--isolated/);
|
|
702
|
+
assert.match(text, /--home/);
|
|
703
|
+
assert.match(text, /\bhome\b/);
|
|
704
|
+
assert.match(text, /GENTLE_SHELL_PI/);
|
|
705
|
+
assert.match(text, /GENTLE_SHELL_HOME/);
|
|
706
|
+
assert.match(text, /PI_CODING_AGENT_DIR/);
|
|
707
|
+
assert.match(text, /forward/i);
|
|
708
|
+
});
|
|
709
|
+
|
|
710
|
+
test("helpText documents pi's own package-management subcommands", () => {
|
|
711
|
+
const text = helpText();
|
|
712
|
+
assert.match(text, /\binstall\b/);
|
|
713
|
+
assert.match(text, /\bremove\b/);
|
|
714
|
+
assert.match(text, /\blist\b/);
|
|
715
|
+
assert.match(text, /\bupdate\b/);
|
|
716
|
+
assert.match(text, /\bconfig\b/);
|
|
717
|
+
assert.match(text, /\bauth\b/);
|
|
718
|
+
});
|