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,483 @@
|
|
|
1
|
+
// Generated by scripts/build-runtime-modules.mjs. Do not edit.
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
// The gentle-shell launcher: pure, side-effect-free functions over injected
|
|
5
|
+
// env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
|
|
6
|
+
// filesystem and child process so this module stays fully unit-testable.
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
// pi's own package-management subcommands (see pi's cli/args.ts printHelp
|
|
11
|
+
// "Commands" list): each is dispatched by pi itself, before pi's own flag
|
|
12
|
+
// parsing, purely on argv[0]. `uninstall` is pi's alias for `remove`.
|
|
13
|
+
export const PI_SUBCOMMANDS = ["install", "remove", "uninstall", "update", "list", "config", "auth"] ;
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
function isPiSubcommand(token ) {
|
|
18
|
+
return (PI_SUBCOMMANDS ).includes(token);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
// Home-subcommand parsing is deliberately shallow: `home` only counts as the
|
|
39
|
+
// subcommand when it is argv[0], and everything after it is handed over
|
|
40
|
+
// untouched as commandArgs — T2 owns interpreting `home link|isolated|<path>`.
|
|
41
|
+
export function parseLauncherArgs(argv ) {
|
|
42
|
+
if (argv[0] === "home") {
|
|
43
|
+
return {
|
|
44
|
+
link: false,
|
|
45
|
+
isolated: false,
|
|
46
|
+
home: undefined,
|
|
47
|
+
help: false,
|
|
48
|
+
version: false,
|
|
49
|
+
command: "home",
|
|
50
|
+
commandArgs: argv.slice(1),
|
|
51
|
+
passthrough: [],
|
|
52
|
+
piSubcommand: undefined,
|
|
53
|
+
error: undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let link = false;
|
|
58
|
+
let isolated = false;
|
|
59
|
+
let home ;
|
|
60
|
+
let help = false;
|
|
61
|
+
let version = false;
|
|
62
|
+
let error ;
|
|
63
|
+
let piSubcommand ;
|
|
64
|
+
const passthrough = [];
|
|
65
|
+
|
|
66
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
67
|
+
const arg = argv[i];
|
|
68
|
+
if (arg === "--") {
|
|
69
|
+
const rest = argv.slice(i + 1);
|
|
70
|
+
if (passthrough.length === 0 && rest.length > 0 && isPiSubcommand(rest[0])) {
|
|
71
|
+
piSubcommand = rest[0];
|
|
72
|
+
}
|
|
73
|
+
passthrough.push(...rest);
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (arg === "--link") {
|
|
77
|
+
link = true;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (arg === "--isolated") {
|
|
81
|
+
isolated = true;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (arg === "--help" || arg === "-h") {
|
|
85
|
+
help = true;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (arg === "--version") {
|
|
89
|
+
version = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (arg.startsWith("--home=")) {
|
|
93
|
+
const value = arg.slice("--home=".length);
|
|
94
|
+
if (value.length === 0) {
|
|
95
|
+
error = "--home requires a non-empty path argument";
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
home = value;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (arg === "--home") {
|
|
102
|
+
const value = argv[i + 1];
|
|
103
|
+
if (value === undefined || value.length === 0) {
|
|
104
|
+
error = "--home requires a non-empty path argument";
|
|
105
|
+
if (value !== undefined) i += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
home = value;
|
|
109
|
+
i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (passthrough.length === 0 && isPiSubcommand(arg)) {
|
|
113
|
+
piSubcommand = arg;
|
|
114
|
+
}
|
|
115
|
+
passthrough.push(arg);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (error === undefined) {
|
|
119
|
+
if (link && isolated) {
|
|
120
|
+
error = "--link cannot be combined with --isolated";
|
|
121
|
+
} else if (link && home !== undefined) {
|
|
122
|
+
error = "--link cannot be combined with --home";
|
|
123
|
+
} else if (isolated && home !== undefined) {
|
|
124
|
+
error = "--isolated cannot be combined with --home";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return { link, isolated, home, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- home resolution -------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
// A discriminated union instead of a plain `home: string` field: `resolveHome`
|
|
143
|
+
// switches on `mode` rather than re-parsing the raw on-disk string, and the
|
|
144
|
+
// `path` case carries its `dir` explicitly so a "link"/"isolated" string can
|
|
145
|
+
// never be mistaken for a filesystem path at the call site.
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
// Pi Subagents resolves `PI_CODING_AGENT_DIR || ~/.pi/agent`; `--link` reuses
|
|
156
|
+
// that exact home so gentle-shell never diverges from the user's own pi.
|
|
157
|
+
function linkDir(env , homedir ) {
|
|
158
|
+
return env.PI_CODING_AGENT_DIR || join(homedir, ".pi", "agent");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isolatedDir(env , homedir ) {
|
|
162
|
+
return env.GENTLE_SHELL_HOME || join(homedir, ".gentle-shell", "agent");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function resolveHome(input ) {
|
|
166
|
+
const { args, env, homedir, config } = input;
|
|
167
|
+
|
|
168
|
+
if (args.link) return { mode: "link", dir: linkDir(env, homedir), source: "flag" };
|
|
169
|
+
if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" };
|
|
170
|
+
if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" };
|
|
171
|
+
|
|
172
|
+
if (config !== undefined) {
|
|
173
|
+
if (config.mode === "link") return { mode: "link", dir: linkDir(env, homedir), source: "config" };
|
|
174
|
+
if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" };
|
|
175
|
+
return { mode: "path", dir: config.dir, source: "config" };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function launcherConfigPath(homedir ) {
|
|
182
|
+
return join(homedir, ".gentle-shell", "config.json");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Tolerant on purpose: a malformed or foreign config.json must never crash
|
|
186
|
+
// the launcher, it just falls through to the default isolated home.
|
|
187
|
+
//
|
|
188
|
+
// The on-disk shape stays the flat `{ "home": "link" | "isolated" | "<path>" }`
|
|
189
|
+
// documented in the feature scope; only the parsed, in-memory `LauncherConfig`
|
|
190
|
+
// is a discriminated union. Any non-empty string other than the exact literals
|
|
191
|
+
// "link" or "isolated" is treated as a path, including a near-miss like
|
|
192
|
+
// "linked" — this is deliberate: there is no separate "unrecognised mode"
|
|
193
|
+
// error, a typo just resolves to a (probably nonexistent) path instead.
|
|
194
|
+
export function parseLauncherConfig(text ) {
|
|
195
|
+
let parsed ;
|
|
196
|
+
try {
|
|
197
|
+
parsed = JSON.parse(text);
|
|
198
|
+
} catch {
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
202
|
+
const home = (parsed ).home;
|
|
203
|
+
if (typeof home !== "string" || home.length === 0) return undefined;
|
|
204
|
+
if (home === "link") return { mode: "link" };
|
|
205
|
+
if (home === "isolated") return { mode: "isolated" };
|
|
206
|
+
return { mode: "path", dir: home };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- pi runtime resolution ---------------------------------------------------
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
export function resolvePiRuntime(deps ) {
|
|
227
|
+
const envOverride = deps.env.GENTLE_SHELL_PI;
|
|
228
|
+
if (envOverride !== undefined && envOverride.length > 0) return { kind: "env", command: envOverride, args: [] };
|
|
229
|
+
|
|
230
|
+
const bundledCliPath = deps.resolveBundledCli();
|
|
231
|
+
if (bundledCliPath !== undefined) return { kind: "bundled", command: deps.nodeExecPath, args: [bundledCliPath] };
|
|
232
|
+
|
|
233
|
+
const onPath = deps.findOnPath("pi");
|
|
234
|
+
if (onPath !== undefined) return { kind: "path", command: onPath, args: [] };
|
|
235
|
+
|
|
236
|
+
return undefined;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function missingPiMessage() {
|
|
240
|
+
return [
|
|
241
|
+
"No pi runtime could be found. Pick one of:",
|
|
242
|
+
" - Set GENTLE_SHELL_PI to the path of a pi executable.",
|
|
243
|
+
" - Install @earendil-works/pi-coding-agent next to gentle-pi (it ships as an optional peer dependency).",
|
|
244
|
+
" - Install pi and make sure it is on your PATH.",
|
|
245
|
+
].join("\n");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// --- pi version gate ---------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export const MIN_PI_VERSION = "0.85.1";
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
const VERSION_PATTERN = /v?(\d+)\.(\d+)\.(\d+)/;
|
|
255
|
+
|
|
256
|
+
function compareVersions(a , b ) {
|
|
257
|
+
for (let i = 0; i < 3; i += 1) {
|
|
258
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
259
|
+
}
|
|
260
|
+
return 0;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function checkPiVersion(output , minimum = MIN_PI_VERSION) {
|
|
264
|
+
const match = VERSION_PATTERN.exec(output);
|
|
265
|
+
if (!match) {
|
|
266
|
+
return { ok: false, message: `Could not determine the pi version from "${output.trim()}" (need at least ${minimum}).` };
|
|
267
|
+
}
|
|
268
|
+
const version = `${match[1]}.${match[2]}.${match[3]}`;
|
|
269
|
+
const minimumMatch = VERSION_PATTERN.exec(minimum);
|
|
270
|
+
if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
|
|
271
|
+
const found = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
272
|
+
const wanted = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
|
|
273
|
+
if (compareVersions(found, wanted) < 0) {
|
|
274
|
+
return { ok: false, version, message: `pi version ${version} is older than the required minimum ${minimum}.` };
|
|
275
|
+
}
|
|
276
|
+
return { ok: true, version };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// --- packaging drift guard -----------------------------------------------------
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
// Keeps the MIN_PI_VERSION drift-guard test's failure readable: a missing
|
|
288
|
+
// peerDependencies block, a missing peer entry, or a malformed range must
|
|
289
|
+
// fail with a clear assertion message, not a raw TypeError from indexing an
|
|
290
|
+
// undefined value the way a direct `packageJson.peerDependencies[peerName]`
|
|
291
|
+
// lookup would.
|
|
292
|
+
export function checkPeerVersionPin(packageJson , peerName , minVersion ) {
|
|
293
|
+
const peerDependencies = packageJson.peerDependencies;
|
|
294
|
+
if (peerDependencies === undefined) {
|
|
295
|
+
return { ok: false, message: "package.json is missing a peerDependencies block" };
|
|
296
|
+
}
|
|
297
|
+
const pinned = peerDependencies[peerName];
|
|
298
|
+
if (typeof pinned !== "string") {
|
|
299
|
+
return { ok: false, message: `package.json peerDependencies is missing "${peerName}"` };
|
|
300
|
+
}
|
|
301
|
+
if (!/^>=\d+\.\d+\.\d+$/.test(pinned)) {
|
|
302
|
+
return { ok: false, message: `package.json peerDependencies["${peerName}"] ("${pinned}") is not a simple >=x.y.z range` };
|
|
303
|
+
}
|
|
304
|
+
const version = pinned.replace(/^>=/, "");
|
|
305
|
+
if (version !== minVersion) {
|
|
306
|
+
return { ok: false, message: `MIN_PI_VERSION ("${minVersion}") does not match the pinned peer range ("${pinned}")` };
|
|
307
|
+
}
|
|
308
|
+
return { ok: true, pinned };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- settings.json detection ---------------------------------------------------
|
|
312
|
+
|
|
313
|
+
function packageEntryDeclaresGentlePi(entry ) {
|
|
314
|
+
const declares = (value ) => typeof value === "string" && (value === "npm:gentle-pi" || value.startsWith("npm:gentle-pi@"));
|
|
315
|
+
if (declares(entry)) return true;
|
|
316
|
+
if (entry !== null && typeof entry === "object") return declares((entry ).source);
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export function settingsDeclareGentlePi(settingsText ) {
|
|
321
|
+
if (settingsText === undefined) return false;
|
|
322
|
+
let parsed ;
|
|
323
|
+
try {
|
|
324
|
+
parsed = JSON.parse(settingsText);
|
|
325
|
+
} catch {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (typeof parsed !== "object" || parsed === null) return false;
|
|
329
|
+
const packages = (parsed ).packages;
|
|
330
|
+
if (!Array.isArray(packages)) return false;
|
|
331
|
+
return packages.some(packageEntryDeclaresGentlePi);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// --- pi invocation builder ---------------------------------------------------
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
// The `-e/--theme/--skill/--prompt-template` injection is skipped when the
|
|
357
|
+
// caller already confirmed the target settings.json declares the package
|
|
358
|
+
// (the `--link` case with a pi-managed install), or when passthrough[0] is
|
|
359
|
+
// one of pi's own subcommands: pi dispatches install/remove/uninstall/
|
|
360
|
+
// update/list/config/auth on argv[0] before it even parses flags, so any
|
|
361
|
+
// injected flag ahead of it stops pi from recognising its subcommand at
|
|
362
|
+
// all — this is exactly the observed 2026-09-22 bug where `gentle-shell
|
|
363
|
+
// install npm:x` opened an interactive pi session instead of running the
|
|
364
|
+
// package manager. Isolated and path homes never declare the package, so
|
|
365
|
+
// callers pass `settingsDeclareGentlePi: false` for those and the
|
|
366
|
+
// injection always happens there, unless a pi subcommand is set.
|
|
367
|
+
export function buildPiInvocation(input ) {
|
|
368
|
+
const args = [...input.runtime.args];
|
|
369
|
+
if (input.piSubcommand === undefined && !input.settingsDeclareGentlePi) {
|
|
370
|
+
args.push(
|
|
371
|
+
"-e",
|
|
372
|
+
input.packageRoot,
|
|
373
|
+
"--theme",
|
|
374
|
+
join(input.packageRoot, "themes"),
|
|
375
|
+
"--skill",
|
|
376
|
+
join(input.packageRoot, "skills"),
|
|
377
|
+
"--prompt-template",
|
|
378
|
+
join(input.packageRoot, "prompts"),
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
args.push(...input.passthrough);
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
command: input.runtime.command,
|
|
385
|
+
args,
|
|
386
|
+
env: { ...input.baseEnv, PI_CODING_AGENT_DIR: input.home.dir, GENTLE_PI_AGENT_HOME: input.home.dir },
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// --- spawn planning ------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
// R3-001: `findOnPath` can resolve a PATHEXT candidate such as a .CMD or .BAT
|
|
393
|
+
// shim on win32 (exactly how an npm-installed `pi` lands on PATH), and a
|
|
394
|
+
// GENTLE_SHELL_PI override can point at one too. Current Node releases refuse
|
|
395
|
+
// to spawn a batch file directly without `shell: true` (EINVAL), so both the
|
|
396
|
+
// version probe and the real launch route a batch shim through cmd.exe as one
|
|
397
|
+
// quoted command line instead of spawning it directly.
|
|
398
|
+
const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
|
|
399
|
+
|
|
400
|
+
// cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
|
|
401
|
+
// token in double quotes when it is empty or contains whitespace or any of
|
|
402
|
+
// `"&|<>^%()`, and escape an inner `"` as `\"` — doubling inner quotes is not
|
|
403
|
+
// reliable in cmd.exe, unlike the `\"` convention Node's own Windows spawn
|
|
404
|
+
// helpers use.
|
|
405
|
+
export function quoteForCmdExe(token ) {
|
|
406
|
+
if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
|
|
407
|
+
return `"${token.replace(/"/g, '\\"')}"`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
export function planSpawn(input ) {
|
|
423
|
+
const { command, args, platform } = input;
|
|
424
|
+
if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
|
|
425
|
+
return { command: [command, ...args].map(quoteForCmdExe).join(" "), args: [], shell: true };
|
|
426
|
+
}
|
|
427
|
+
return { command, args, shell: false };
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// --- reporting ---------------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
export function describeVersion(input ) {
|
|
439
|
+
return [
|
|
440
|
+
`gentle-shell ${input.gentlePiVersion}`,
|
|
441
|
+
`pi ${input.piVersion ?? "not found"}`,
|
|
442
|
+
`home ${input.home.mode} ${input.home.dir}`,
|
|
443
|
+
].join("\n");
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function helpText() {
|
|
447
|
+
return [
|
|
448
|
+
"Usage: gentle-shell [options] [-- pi-args...]",
|
|
449
|
+
" gentle-shell home [link|isolated|<path>]",
|
|
450
|
+
"",
|
|
451
|
+
"Opens pi with the Gentle Shell package loaded, without touching your",
|
|
452
|
+
"vanilla pi installation.",
|
|
453
|
+
"",
|
|
454
|
+
"Options:",
|
|
455
|
+
" --link Use your existing pi agent home (never edits its settings.json).",
|
|
456
|
+
" --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
|
|
457
|
+
" --home <path> Use a custom agent home directory.",
|
|
458
|
+
" --help, -h Show this help text.",
|
|
459
|
+
" --version Show gentle-shell, pi, and home version information.",
|
|
460
|
+
"",
|
|
461
|
+
"Commands:",
|
|
462
|
+
" home Print or persist the effective home mode (link, isolated, or a path).",
|
|
463
|
+
"",
|
|
464
|
+
"Managing packages:",
|
|
465
|
+
" gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",
|
|
466
|
+
" gentle-shell remove <source> Run pi's own 'remove' against the resolved home.",
|
|
467
|
+
" gentle-shell list Run pi's own 'list' against the resolved home.",
|
|
468
|
+
" gentle-shell update [target] Run pi's own 'update' against the resolved home.",
|
|
469
|
+
" gentle-shell config Run pi's own 'config' against the resolved home.",
|
|
470
|
+
" gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
|
|
471
|
+
" These run pi's own commands, forwarded verbatim, against the --isolated home",
|
|
472
|
+
" (or your own pi home with --link). Running 'gentle-shell install npm:gentle-pi'",
|
|
473
|
+
" inside the isolated home is unnecessary: gentle-shell already loads the",
|
|
474
|
+
" package itself.",
|
|
475
|
+
"",
|
|
476
|
+
"Environment variables:",
|
|
477
|
+
" GENTLE_SHELL_PI Path to the pi executable to run.",
|
|
478
|
+
" GENTLE_SHELL_HOME Directory for the isolated home (default: ~/.gentle-shell/agent).",
|
|
479
|
+
" PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.",
|
|
480
|
+
"",
|
|
481
|
+
"Every other argument is forwarded to pi unchanged.",
|
|
482
|
+
].join("\n");
|
|
483
|
+
}
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// hardcoded copies of it survived a pin bump once and reported installing
|
|
4
4
|
// v2.1.11 while writing v2.2.0 to disk, which is the one moment an operator
|
|
5
5
|
// most needs the number to be true.
|
|
6
|
+
import { dirname } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
6
8
|
import { INSTALLER_VERSION, installGentleAi } from "./gentle-ai-installer.mjs";
|
|
7
|
-
import { installTuiModeSetting } from "./install-tui-mode-setting.mjs";
|
|
9
|
+
import { installTuiModeSetting, isPiManagedInstall } from "./install-tui-mode-setting.mjs";
|
|
8
10
|
|
|
9
11
|
if (process.env.GENTLE_PI_SKIP_GENTLE_AI_INSTALL === "1") {
|
|
10
12
|
console.warn("GENTLE_PI_SKIP_GENTLE_AI_INSTALL=1: skipped package-local Gentle AI installation; native review operations will fail with package-local-binary-missing until gentle-pi is reinstalled.");
|
|
@@ -20,11 +22,16 @@ if (process.env.GENTLE_PI_SKIP_GENTLE_AI_INSTALL === "1") {
|
|
|
20
22
|
|
|
21
23
|
// A native failure never changes settings; the explicit native-only skip does.
|
|
22
24
|
if (!process.exitCode) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
const packageDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
if (!isPiManagedInstall(packageDir)) {
|
|
27
|
+
console.log(`gentle-pi skipped enabling fullscreen in global Pi settings: ${packageDir} is not a pi-managed install (npm install -g, a git checkout, and npx all land here).`);
|
|
28
|
+
} else {
|
|
29
|
+
try {
|
|
30
|
+
const result = await installTuiModeSetting();
|
|
31
|
+
if (result.changed) console.log("gentle-pi enabled fullscreen in global Pi settings; /settings can switch back to regular.");
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.error(`gentle-pi could not enable fullscreen: ${error instanceof Error ? error.message : String(error)}`);
|
|
34
|
+
process.exitCode = 1;
|
|
35
|
+
}
|
|
29
36
|
}
|
|
30
37
|
}
|
|
@@ -1,10 +1,44 @@
|
|
|
1
1
|
import { closeSync, constants, fchmodSync, fsyncSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { randomUUID } from "node:crypto";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
7
|
|
|
8
|
+
// The two directory layouts Pi's own package manager creates when it installs
|
|
9
|
+
// a package into an agent home: the npm-backed `npm/node_modules/<package>`
|
|
10
|
+
// (user scope `<agent dir>/npm/node_modules/gentle-pi`, project scope
|
|
11
|
+
// `.pi/npm/node_modules/gentle-pi`) and the git-backed
|
|
12
|
+
// `git/github.com/Gentleman-Programming/<package>` layout. A path is checked
|
|
13
|
+
// for these sequences anywhere in its segments, not anchored to a specific
|
|
14
|
+
// resolved agent home — unlike installTuiModeSetting's own ownership check.
|
|
15
|
+
const PI_MANAGED_SEGMENT_SEQUENCES = [
|
|
16
|
+
["npm", "node_modules"],
|
|
17
|
+
["git", "github.com", "Gentleman-Programming"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function containsSequence(segments, sequence) {
|
|
21
|
+
for (let start = 0; start + sequence.length <= segments.length; start += 1) {
|
|
22
|
+
if (sequence.every((part, offset) => segments[start + offset] === part)) return true;
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Gates the POSTINSTALL entry point only (scripts/install-gentle-ai.mjs), not
|
|
28
|
+
* installTuiModeSetting or installIsolatedTuiModeSetting: true when
|
|
29
|
+
* `packageDir` (the directory of the gentle-pi package actually running,
|
|
30
|
+
* typically derived from that script's own import.meta.url) sits under one of
|
|
31
|
+
* the directory layouts above. A plain `npm install -g gentle-pi`, a
|
|
32
|
+
* development git checkout, an `npx` cache directory, or a pnpm store never
|
|
33
|
+
* match, so the postinstall entry skips writing the user's global Pi settings
|
|
34
|
+
* for those instead of relying solely on installTuiModeSetting's own,
|
|
35
|
+
* differently-scoped ownership check.
|
|
36
|
+
*/
|
|
37
|
+
export function isPiManagedInstall(packageDir) {
|
|
38
|
+
const segments = resolve(packageDir).split(sep);
|
|
39
|
+
return PI_MANAGED_SEGMENT_SEQUENCES.some((sequence) => containsSequence(segments, sequence));
|
|
40
|
+
}
|
|
41
|
+
|
|
8
42
|
function inspect(path) {
|
|
9
43
|
try { return lstatSync(path); }
|
|
10
44
|
catch (error) { if (error.code === "ENOENT") return undefined; throw error; }
|
|
@@ -112,3 +146,46 @@ export async function installTuiModeSetting(options = {}) {
|
|
|
112
146
|
}
|
|
113
147
|
}
|
|
114
148
|
}
|
|
149
|
+
|
|
150
|
+
/** Writes tuiMode: "fullscreen" into a directory gentle-shell's own isolated-home
|
|
151
|
+
* bootstrap (T2) just created and owns. Unlike installTuiModeSetting, there is no
|
|
152
|
+
* "physically installed under this home's npm/node_modules" ownership check to
|
|
153
|
+
* satisfy: the caller already knows it created `dir` moments ago as gentle-shell's
|
|
154
|
+
* dedicated agent home, so the only safety property that still matters is the one
|
|
155
|
+
* every writer here needs — atomic, non-symlink, cooperative-lock-respecting.
|
|
156
|
+
*/
|
|
157
|
+
export async function installIsolatedTuiModeSetting(dir) {
|
|
158
|
+
const home = realpathSync(resolve(dir));
|
|
159
|
+
assertDirectories([home]);
|
|
160
|
+
const settingsPath = join(home, "settings.json");
|
|
161
|
+
const lockPath = `${settingsPath}.lock`;
|
|
162
|
+
const lock = await acquireLock(lockPath);
|
|
163
|
+
const started = Date.now();
|
|
164
|
+
let staging;
|
|
165
|
+
try {
|
|
166
|
+
assertDirectories([home]);
|
|
167
|
+
const original = readSettings(settingsPath);
|
|
168
|
+
if (original.value.tuiMode === "fullscreen") return { changed: false, recognized: true };
|
|
169
|
+
staging = join(home, `.settings-fullscreen-${randomUUID()}.tmp`);
|
|
170
|
+
const fd = openSync(staging, "wx", original.stat ? original.stat.mode & 0o777 : 0o600);
|
|
171
|
+
try {
|
|
172
|
+
if (original.stat) fchmodSync(fd, original.stat.mode & 0o777);
|
|
173
|
+
writeFileSync(fd, `${JSON.stringify({ ...original.value, tuiMode: "fullscreen" }, null, 2)}\n`, "utf8");
|
|
174
|
+
fsyncSync(fd);
|
|
175
|
+
} finally { closeSync(fd); }
|
|
176
|
+
assertDirectories([home]);
|
|
177
|
+
const latest = readSettings(settingsPath);
|
|
178
|
+
if (latest.text !== original.text || (original.stat ? !sameFile(original.stat, latest.stat) : latest.stat !== undefined)) {
|
|
179
|
+
throw new Error("settings.json changed concurrently; retry installation");
|
|
180
|
+
}
|
|
181
|
+
if (!sameFile(lock, inspect(lockPath)) || Date.now() - started >= 5000) throw new Error("Fullscreen settings lock ownership expired; retry installation");
|
|
182
|
+
renameSync(staging, settingsPath);
|
|
183
|
+
staging = undefined;
|
|
184
|
+
return { changed: true, recognized: true };
|
|
185
|
+
} finally {
|
|
186
|
+
if (realpathSync(home) === home) {
|
|
187
|
+
if (staging) unlinkSync(staging);
|
|
188
|
+
if (sameFile(lock, inspect(lockPath))) rmdirSync(lockPath);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
@@ -8,6 +8,7 @@ import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
8
8
|
const root = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
9
9
|
|
|
10
10
|
const requiredPaths = [
|
|
11
|
+
"bin/gentle-shell.mjs",
|
|
11
12
|
"assets/orchestrator.md",
|
|
12
13
|
"assets/orchestrator-delegation.md",
|
|
13
14
|
"assets/orchestrator-memory.md",
|
|
@@ -54,6 +55,7 @@ const requiredPaths = [
|
|
|
54
55
|
"extensions/sdd-init.ts",
|
|
55
56
|
"extensions/skill-registry.ts",
|
|
56
57
|
"lib/gentle-ai-binary.ts",
|
|
58
|
+
"lib/gentle-shell-launcher.ts",
|
|
57
59
|
"lib/native-review-cli.ts",
|
|
58
60
|
"lib/provider-contract-bundle.ts",
|
|
59
61
|
"lib/review-host-relay.ts",
|
|
@@ -62,6 +64,7 @@ const requiredPaths = [
|
|
|
62
64
|
"lib/sdd-preflight.ts",
|
|
63
65
|
"lib/telemetry-trigger.ts",
|
|
64
66
|
"runtime/gentle-ai-binary.mjs",
|
|
67
|
+
"runtime/gentle-shell-launcher.mjs",
|
|
65
68
|
"runtime/native-review-cli.mjs",
|
|
66
69
|
"runtime/review-integration-v2.mjs",
|
|
67
70
|
"runtime/review-risk-assessment.mjs",
|
|
@@ -70,6 +73,7 @@ const requiredPaths = [
|
|
|
70
73
|
"scripts/check-provider-contract.mjs",
|
|
71
74
|
"scripts/gentle-ai-installer.mjs",
|
|
72
75
|
"scripts/install-gentle-ai.mjs",
|
|
76
|
+
"scripts/install-tui-mode-setting.mjs",
|
|
73
77
|
"scripts/mirror-provider-contract.mjs",
|
|
74
78
|
"tests/fixtures/native-review-cli/v2.1.3/start.json",
|
|
75
79
|
"tests/fixtures/provider-contract-bundle/v1.1.0/README.md",
|