gentle-pi 3.4.0 → 3.5.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 +45 -18
- package/bin/gentle-shell.mjs +373 -0
- package/docs/gentle-agents-activity.md +95 -0
- package/docs/readme-reference.md +129 -11
- 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 +867 -0
- package/lib/rpc-host.ts +36 -0
- package/package.json +5 -1
- package/runtime/gentle-shell-launcher.mjs +868 -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 +473 -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 +136 -0
- package/tests/gentle-shell-bin.test.ts +836 -0
- package/tests/gentle-shell-launcher.test.ts +1367 -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,867 @@
|
|
|
1
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
2
|
+
|
|
3
|
+
// The gentle-shell launcher: pure, side-effect-free functions over injected
|
|
4
|
+
// env/fs/exec. `bin/gentle-shell.mjs` (T2) wires these into the real process,
|
|
5
|
+
// filesystem and child process so this module stays fully unit-testable.
|
|
6
|
+
|
|
7
|
+
export type LauncherCommand = "home";
|
|
8
|
+
|
|
9
|
+
// pi's own package-management subcommands (see pi's cli/args.ts printHelp
|
|
10
|
+
// "Commands" list): each is dispatched by pi itself, before pi's own flag
|
|
11
|
+
// parsing, purely on argv[0]. `uninstall` is pi's alias for `remove`.
|
|
12
|
+
export const PI_SUBCOMMANDS = ["install", "remove", "uninstall", "update", "list", "config", "auth"] as const;
|
|
13
|
+
|
|
14
|
+
export type PiSubcommand = (typeof PI_SUBCOMMANDS)[number];
|
|
15
|
+
|
|
16
|
+
function isPiSubcommand(token: string): token is PiSubcommand {
|
|
17
|
+
return (PI_SUBCOMMANDS as readonly string[]).includes(token);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ParsedLauncherArgs {
|
|
21
|
+
link: boolean;
|
|
22
|
+
isolated: boolean;
|
|
23
|
+
home?: string;
|
|
24
|
+
packageRoot?: string;
|
|
25
|
+
help: boolean;
|
|
26
|
+
version: boolean;
|
|
27
|
+
command?: LauncherCommand;
|
|
28
|
+
commandArgs: string[];
|
|
29
|
+
passthrough: string[];
|
|
30
|
+
// Set when the first passthrough token is one of PI_SUBCOMMANDS (e.g.
|
|
31
|
+
// `gentle-shell install npm:x`). It stays part of `passthrough` — this
|
|
32
|
+
// field only tells buildPiInvocation to skip its extension injection, so
|
|
33
|
+
// pi sees the bare subcommand it expects as argv[0].
|
|
34
|
+
piSubcommand?: PiSubcommand;
|
|
35
|
+
error?: string;
|
|
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: string[]): ParsedLauncherArgs {
|
|
42
|
+
if (argv[0] === "home") {
|
|
43
|
+
return {
|
|
44
|
+
link: false,
|
|
45
|
+
isolated: false,
|
|
46
|
+
home: undefined,
|
|
47
|
+
packageRoot: undefined,
|
|
48
|
+
help: false,
|
|
49
|
+
version: false,
|
|
50
|
+
command: "home",
|
|
51
|
+
commandArgs: argv.slice(1),
|
|
52
|
+
passthrough: [],
|
|
53
|
+
piSubcommand: undefined,
|
|
54
|
+
error: undefined,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let link = false;
|
|
59
|
+
let isolated = false;
|
|
60
|
+
let home: string | undefined;
|
|
61
|
+
let packageRoot: string | undefined;
|
|
62
|
+
let help = false;
|
|
63
|
+
let version = false;
|
|
64
|
+
let error: string | undefined;
|
|
65
|
+
let piSubcommand: PiSubcommand | undefined;
|
|
66
|
+
const passthrough: string[] = [];
|
|
67
|
+
|
|
68
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
69
|
+
const arg = argv[i];
|
|
70
|
+
if (arg === "--") {
|
|
71
|
+
const rest = argv.slice(i + 1);
|
|
72
|
+
if (passthrough.length === 0 && rest.length > 0 && isPiSubcommand(rest[0])) {
|
|
73
|
+
piSubcommand = rest[0];
|
|
74
|
+
}
|
|
75
|
+
passthrough.push(...rest);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
if (arg === "--link") {
|
|
79
|
+
link = true;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (arg === "--isolated") {
|
|
83
|
+
isolated = true;
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (arg === "--help" || arg === "-h") {
|
|
87
|
+
help = true;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (arg === "--version") {
|
|
91
|
+
version = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (arg.startsWith("--home=")) {
|
|
95
|
+
const value = arg.slice("--home=".length);
|
|
96
|
+
if (value.length === 0) {
|
|
97
|
+
error = "--home requires a non-empty path argument";
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
home = value;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (arg === "--home") {
|
|
104
|
+
const value = argv[i + 1];
|
|
105
|
+
if (value === undefined || value.length === 0) {
|
|
106
|
+
error = "--home requires a non-empty path argument";
|
|
107
|
+
if (value !== undefined) i += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
home = value;
|
|
111
|
+
i += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (arg.startsWith("--package-root=")) {
|
|
115
|
+
const value = arg.slice("--package-root=".length);
|
|
116
|
+
if (value.length === 0) {
|
|
117
|
+
error = "--package-root requires a non-empty path argument";
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
packageRoot = value;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (arg === "--package-root") {
|
|
124
|
+
const value = argv[i + 1];
|
|
125
|
+
if (value === undefined || value.length === 0) {
|
|
126
|
+
error = "--package-root requires a non-empty path argument";
|
|
127
|
+
if (value !== undefined) i += 1;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
packageRoot = value;
|
|
131
|
+
i += 1;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (passthrough.length === 0 && isPiSubcommand(arg)) {
|
|
135
|
+
piSubcommand = arg;
|
|
136
|
+
}
|
|
137
|
+
passthrough.push(arg);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (error === undefined) {
|
|
141
|
+
if (link && isolated) {
|
|
142
|
+
error = "--link cannot be combined with --isolated";
|
|
143
|
+
} else if (link && home !== undefined) {
|
|
144
|
+
error = "--link cannot be combined with --home";
|
|
145
|
+
} else if (isolated && home !== undefined) {
|
|
146
|
+
error = "--isolated cannot be combined with --home";
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return { link, isolated, home, packageRoot, help, version, command: undefined, commandArgs: [], passthrough, piSubcommand, error };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- home resolution -------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
export type HomeMode = "link" | "isolated" | "path";
|
|
156
|
+
export type HomeSource = "flag" | "config" | "default";
|
|
157
|
+
|
|
158
|
+
export interface ResolvedHome {
|
|
159
|
+
mode: HomeMode;
|
|
160
|
+
dir: string;
|
|
161
|
+
source: HomeSource;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// A discriminated union instead of a plain `home: string` field: `resolveHome`
|
|
165
|
+
// switches on `mode` rather than re-parsing the raw on-disk string, and the
|
|
166
|
+
// `path` case carries its `dir` explicitly so a "link"/"isolated" string can
|
|
167
|
+
// never be mistaken for a filesystem path at the call site.
|
|
168
|
+
export type LauncherConfig = { mode: "link" } | { mode: "isolated" } | { mode: "path"; dir: string };
|
|
169
|
+
|
|
170
|
+
export interface ResolveHomeInput {
|
|
171
|
+
args: ParsedLauncherArgs;
|
|
172
|
+
env: Record<string, string | undefined>;
|
|
173
|
+
homedir: string;
|
|
174
|
+
config: LauncherConfig | undefined;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Pi Subagents resolves `PI_CODING_AGENT_DIR || ~/.pi/agent`; `--link` reuses
|
|
178
|
+
// that exact home so gentle-shell never diverges from the user's own pi.
|
|
179
|
+
function linkDir(env: Record<string, string | undefined>, homedir: string): string {
|
|
180
|
+
return env.PI_CODING_AGENT_DIR || join(homedir, ".pi", "agent");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function isolatedDir(env: Record<string, string | undefined>, homedir: string): string {
|
|
184
|
+
return env.GENTLE_SHELL_HOME || join(homedir, ".gentle-shell", "agent");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resolveHome(input: ResolveHomeInput): ResolvedHome {
|
|
188
|
+
const { args, env, homedir, config } = input;
|
|
189
|
+
|
|
190
|
+
if (args.link) return { mode: "link", dir: linkDir(env, homedir), source: "flag" };
|
|
191
|
+
if (args.isolated) return { mode: "isolated", dir: isolatedDir(env, homedir), source: "flag" };
|
|
192
|
+
if (args.home !== undefined) return { mode: "path", dir: args.home, source: "flag" };
|
|
193
|
+
|
|
194
|
+
if (config !== undefined) {
|
|
195
|
+
if (config.mode === "link") return { mode: "link", dir: linkDir(env, homedir), source: "config" };
|
|
196
|
+
if (config.mode === "isolated") return { mode: "isolated", dir: isolatedDir(env, homedir), source: "config" };
|
|
197
|
+
return { mode: "path", dir: config.dir, source: "config" };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return { mode: "isolated", dir: isolatedDir(env, homedir), source: "default" };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function launcherConfigPath(homedir: string): string {
|
|
204
|
+
return join(homedir, ".gentle-shell", "config.json");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Tolerant on purpose: a malformed or foreign config.json must never crash
|
|
208
|
+
// the launcher, it just falls through to the default isolated home.
|
|
209
|
+
//
|
|
210
|
+
// The on-disk shape stays the flat `{ "home": "link" | "isolated" | "<path>" }`
|
|
211
|
+
// documented in the feature scope; only the parsed, in-memory `LauncherConfig`
|
|
212
|
+
// is a discriminated union. Any non-empty string other than the exact literals
|
|
213
|
+
// "link" or "isolated" is treated as a path, including a near-miss like
|
|
214
|
+
// "linked" — this is deliberate: there is no separate "unrecognised mode"
|
|
215
|
+
// error, a typo just resolves to a (probably nonexistent) path instead.
|
|
216
|
+
export function parseLauncherConfig(text: string): LauncherConfig | undefined {
|
|
217
|
+
let parsed: unknown;
|
|
218
|
+
try {
|
|
219
|
+
parsed = JSON.parse(text);
|
|
220
|
+
} catch {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return undefined;
|
|
224
|
+
const home = (parsed as Record<string, unknown>).home;
|
|
225
|
+
if (typeof home !== "string" || home.length === 0) return undefined;
|
|
226
|
+
if (home === "link") return { mode: "link" };
|
|
227
|
+
if (home === "isolated") return { mode: "isolated" };
|
|
228
|
+
return { mode: "path", dir: home };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- pi runtime resolution ---------------------------------------------------
|
|
232
|
+
|
|
233
|
+
export type PiRuntimeKind = "env" | "bundled" | "path";
|
|
234
|
+
|
|
235
|
+
export interface PiRuntime {
|
|
236
|
+
kind: PiRuntimeKind;
|
|
237
|
+
command: string;
|
|
238
|
+
args: string[];
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export interface PiRuntimeDeps {
|
|
242
|
+
env: Record<string, string | undefined>;
|
|
243
|
+
resolveBundledCli: () => string | undefined;
|
|
244
|
+
findOnPath: (name: string) => string | undefined;
|
|
245
|
+
nodeExecPath: string;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export function resolvePiRuntime(deps: PiRuntimeDeps): PiRuntime | undefined {
|
|
249
|
+
const envOverride = deps.env.GENTLE_SHELL_PI;
|
|
250
|
+
if (envOverride !== undefined && envOverride.length > 0) return { kind: "env", command: envOverride, args: [] };
|
|
251
|
+
|
|
252
|
+
const bundledCliPath = deps.resolveBundledCli();
|
|
253
|
+
if (bundledCliPath !== undefined) return { kind: "bundled", command: deps.nodeExecPath, args: [bundledCliPath] };
|
|
254
|
+
|
|
255
|
+
const onPath = deps.findOnPath("pi");
|
|
256
|
+
if (onPath !== undefined) return { kind: "path", command: onPath, args: [] };
|
|
257
|
+
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function missingPiMessage(): string {
|
|
262
|
+
return [
|
|
263
|
+
"No pi runtime could be found. Pick one of:",
|
|
264
|
+
" - Set GENTLE_SHELL_PI to the path of a pi executable.",
|
|
265
|
+
" - Install @earendil-works/pi-coding-agent next to gentle-pi (it ships as an optional peer dependency).",
|
|
266
|
+
" - Install pi and make sure it is on your PATH.",
|
|
267
|
+
].join("\n");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// --- pi version gate ---------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
export const MIN_PI_VERSION = "0.85.1";
|
|
273
|
+
|
|
274
|
+
export type PiVersionCheck = { ok: true; version: string } | { ok: false; message: string; version?: string };
|
|
275
|
+
|
|
276
|
+
const VERSION_PATTERN = /v?(\d+)\.(\d+)\.(\d+)/;
|
|
277
|
+
|
|
278
|
+
function compareVersions(a: readonly [number, number, number], b: readonly [number, number, number]): number {
|
|
279
|
+
for (let i = 0; i < 3; i += 1) {
|
|
280
|
+
if (a[i] !== b[i]) return a[i] - b[i];
|
|
281
|
+
}
|
|
282
|
+
return 0;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function checkPiVersion(output: string, minimum: string = MIN_PI_VERSION): PiVersionCheck {
|
|
286
|
+
const match = VERSION_PATTERN.exec(output);
|
|
287
|
+
if (!match) {
|
|
288
|
+
return { ok: false, message: `Could not determine the pi version from "${output.trim()}" (need at least ${minimum}).` };
|
|
289
|
+
}
|
|
290
|
+
const version = `${match[1]}.${match[2]}.${match[3]}`;
|
|
291
|
+
const minimumMatch = VERSION_PATTERN.exec(minimum);
|
|
292
|
+
if (!minimumMatch) throw new Error(`invalid minimum version "${minimum}"`);
|
|
293
|
+
const found: [number, number, number] = [Number(match[1]), Number(match[2]), Number(match[3])];
|
|
294
|
+
const wanted: [number, number, number] = [Number(minimumMatch[1]), Number(minimumMatch[2]), Number(minimumMatch[3])];
|
|
295
|
+
if (compareVersions(found, wanted) < 0) {
|
|
296
|
+
return { ok: false, version, message: `pi version ${version} is older than the required minimum ${minimum}.` };
|
|
297
|
+
}
|
|
298
|
+
return { ok: true, version };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// --- packaging drift guard -----------------------------------------------------
|
|
302
|
+
|
|
303
|
+
export interface PackageJsonPeerShape {
|
|
304
|
+
peerDependencies?: Record<string, string>;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export type PeerVersionPinCheck = { ok: true; pinned: string } | { ok: false; message: string };
|
|
308
|
+
|
|
309
|
+
// Keeps the MIN_PI_VERSION drift-guard test's failure readable: a missing
|
|
310
|
+
// peerDependencies block, a missing peer entry, or a malformed range must
|
|
311
|
+
// fail with a clear assertion message, not a raw TypeError from indexing an
|
|
312
|
+
// undefined value the way a direct `packageJson.peerDependencies[peerName]`
|
|
313
|
+
// lookup would.
|
|
314
|
+
export function checkPeerVersionPin(packageJson: PackageJsonPeerShape, peerName: string, minVersion: string): PeerVersionPinCheck {
|
|
315
|
+
const peerDependencies = packageJson.peerDependencies;
|
|
316
|
+
if (peerDependencies === undefined) {
|
|
317
|
+
return { ok: false, message: "package.json is missing a peerDependencies block" };
|
|
318
|
+
}
|
|
319
|
+
const pinned = peerDependencies[peerName];
|
|
320
|
+
if (typeof pinned !== "string") {
|
|
321
|
+
return { ok: false, message: `package.json peerDependencies is missing "${peerName}"` };
|
|
322
|
+
}
|
|
323
|
+
if (!/^>=\d+\.\d+\.\d+$/.test(pinned)) {
|
|
324
|
+
return { ok: false, message: `package.json peerDependencies["${peerName}"] ("${pinned}") is not a simple >=x.y.z range` };
|
|
325
|
+
}
|
|
326
|
+
const version = pinned.replace(/^>=/, "");
|
|
327
|
+
if (version !== minVersion) {
|
|
328
|
+
return { ok: false, message: `MIN_PI_VERSION ("${minVersion}") does not match the pinned peer range ("${pinned}")` };
|
|
329
|
+
}
|
|
330
|
+
return { ok: true, pinned };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// --- settings.json package declaration detection --------------------------
|
|
334
|
+
|
|
335
|
+
// Matches the raw git URL forms pi accepts without a `git:` prefix.
|
|
336
|
+
const GIT_URL_PATTERN = /^(?:https?|ssh|git):\/\//;
|
|
337
|
+
|
|
338
|
+
function entrySource(entry: unknown): string | undefined {
|
|
339
|
+
if (typeof entry === "string") return entry;
|
|
340
|
+
if (entry !== null && typeof entry === "object") {
|
|
341
|
+
const source = (entry as Record<string, unknown>).source;
|
|
342
|
+
if (typeof source === "string") return source;
|
|
343
|
+
}
|
|
344
|
+
return undefined;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export type PackageSourceKind = "npm" | "git" | "path";
|
|
348
|
+
|
|
349
|
+
// A settings `packages` entry is npm- or git-sourced only via an explicit
|
|
350
|
+
// `npm:`/`git:` prefix or a bare git URL; every other source (relative or
|
|
351
|
+
// absolute) is a local path, per pi's own package-source rules.
|
|
352
|
+
export function packageSourceKind(source: string): PackageSourceKind {
|
|
353
|
+
if (source.startsWith("npm:")) return "npm";
|
|
354
|
+
if (source.startsWith("git:")) return "git";
|
|
355
|
+
if (GIT_URL_PATTERN.test(source)) return "git";
|
|
356
|
+
return "path";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function npmSourceDeclaresGentlePi(source: string): boolean {
|
|
360
|
+
return source === "npm:gentle-pi" || source.startsWith("npm:gentle-pi@");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// npm:<name> or npm:<name>@<version>, tolerating a scoped `@scope/name`: only
|
|
364
|
+
// the first `@` *after* the leading scope marker starts a version suffix.
|
|
365
|
+
function npmPackageName(source: string): string {
|
|
366
|
+
const spec = source.slice("npm:".length);
|
|
367
|
+
if (spec.startsWith("@")) {
|
|
368
|
+
const versionAt = spec.indexOf("@", 1);
|
|
369
|
+
return versionAt === -1 ? spec : spec.slice(0, versionAt);
|
|
370
|
+
}
|
|
371
|
+
const versionAt = spec.indexOf("@");
|
|
372
|
+
return versionAt === -1 ? spec : spec.slice(0, versionAt);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function parseSettingsPackages(settingsText: string | undefined): unknown[] | undefined {
|
|
376
|
+
if (settingsText === undefined) return undefined;
|
|
377
|
+
let parsed: unknown;
|
|
378
|
+
try {
|
|
379
|
+
parsed = JSON.parse(settingsText);
|
|
380
|
+
} catch {
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
if (typeof parsed !== "object" || parsed === null) return undefined;
|
|
384
|
+
const packages = (parsed as Record<string, unknown>).packages;
|
|
385
|
+
return Array.isArray(packages) ? packages : undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function packageEntryDeclaresGentlePi(entry: unknown): boolean {
|
|
389
|
+
const source = entrySource(entry);
|
|
390
|
+
return source !== undefined && packageSourceKind(source) === "npm" && npmSourceDeclaresGentlePi(source);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Deprecated: recognises only an `npm:gentle-pi` declaration. Kept as a thin
|
|
394
|
+
// compatibility wrapper over the pre-existing behaviour for any caller that
|
|
395
|
+
// only cares about the npm case; findGentlePiDeclaration below also detects
|
|
396
|
+
// a path package whose own package.json names it "gentle-pi".
|
|
397
|
+
export function settingsDeclareGentlePi(settingsText: string | undefined): boolean {
|
|
398
|
+
const packages = parseSettingsPackages(settingsText);
|
|
399
|
+
if (packages === undefined) return false;
|
|
400
|
+
return packages.some(packageEntryDeclaresGentlePi);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export type GentlePiDeclaration = { kind: "npm" } | { kind: "path"; dir: string };
|
|
404
|
+
|
|
405
|
+
export interface FindGentlePiDeclarationOptions {
|
|
406
|
+
agentDir: string;
|
|
407
|
+
// Injected fs reader: returns <dir>/package.json's "name" field, or
|
|
408
|
+
// undefined when the file is missing, unreadable, or has no string name.
|
|
409
|
+
readPackageName: (dir: string) => string | undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Detects a settings.json `packages` entry that already loads gentle-pi,
|
|
413
|
+
// either as `npm:gentle-pi[@version]` or as a local path (string or object
|
|
414
|
+
// `source`) whose own package.json declares `"name": "gentle-pi"`. Path
|
|
415
|
+
// entries are resolved relative to `opts.agentDir`, matching how pi itself
|
|
416
|
+
// resolves a settings-relative local path.
|
|
417
|
+
export function findGentlePiDeclaration(settingsText: string | undefined, opts: FindGentlePiDeclarationOptions): GentlePiDeclaration | undefined {
|
|
418
|
+
const packages = parseSettingsPackages(settingsText);
|
|
419
|
+
if (packages === undefined) return undefined;
|
|
420
|
+
|
|
421
|
+
for (const entry of packages) {
|
|
422
|
+
const source = entrySource(entry);
|
|
423
|
+
if (source === undefined) continue;
|
|
424
|
+
const kind = packageSourceKind(source);
|
|
425
|
+
if (kind === "npm" && npmSourceDeclaresGentlePi(source)) return { kind: "npm" };
|
|
426
|
+
if (kind === "path") {
|
|
427
|
+
const dir = resolvePath(opts.agentDir, source);
|
|
428
|
+
if (opts.readPackageName(dir) === "gentle-pi") return { kind: "path", dir };
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// --- take-over decision ---------------------------------------------------
|
|
435
|
+
|
|
436
|
+
export interface DecideTakeOverInput {
|
|
437
|
+
declaration: GentlePiDeclaration | undefined;
|
|
438
|
+
realPackageRoot: string;
|
|
439
|
+
// realpath of the declared path dir, when declaration.kind === "path".
|
|
440
|
+
// Falls back to the raw declared dir when the caller could not realpath
|
|
441
|
+
// it (for example the directory does not exist).
|
|
442
|
+
realDeclaredDir?: string;
|
|
443
|
+
// True when the user passed --package-root explicitly: forces a
|
|
444
|
+
// take-over even for a matching npm declaration, so a different
|
|
445
|
+
// checkout can always be tested on demand.
|
|
446
|
+
packageRootExplicit: boolean;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export function decideTakeOver(input: DecideTakeOverInput): boolean {
|
|
450
|
+
if (input.packageRootExplicit) return true;
|
|
451
|
+
if (input.declaration === undefined) return false;
|
|
452
|
+
if (input.declaration.kind === "npm") return false;
|
|
453
|
+
const realDeclaredDir = input.realDeclaredDir ?? input.declaration.dir;
|
|
454
|
+
return realDeclaredDir !== input.realPackageRoot;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// --- other-package injection planning --------------------------------------
|
|
458
|
+
|
|
459
|
+
export interface OtherPackageInjectionsInput {
|
|
460
|
+
settingsText: string | undefined;
|
|
461
|
+
agentDir: string;
|
|
462
|
+
// The gentle-pi declaration being taken over: its own entry is excluded
|
|
463
|
+
// from the result, since it is injected separately as the launcher's
|
|
464
|
+
// own packageRoot.
|
|
465
|
+
skip: GentlePiDeclaration;
|
|
466
|
+
// Existence check for each resolved package directory, injected so this
|
|
467
|
+
// function stays pure and unit-testable without a real filesystem. A
|
|
468
|
+
// declared package whose directory does not exist (a hand-edited
|
|
469
|
+
// settings.json, a failed or interrupted `pi install`, or an npm store
|
|
470
|
+
// laid out somewhere other than <agentDir>/npm/node_modules) is skipped
|
|
471
|
+
// with a warning instead of being handed to pi as an unresolvable `-e`,
|
|
472
|
+
// which pi's module loader fails on with "Cannot find module" (R3-001).
|
|
473
|
+
// Defaults to always-true so a caller that only cares about the pure
|
|
474
|
+
// string resolution (most existing unit tests) does not need to supply
|
|
475
|
+
// a filesystem stub.
|
|
476
|
+
isDirectory?: (dir: string) => boolean;
|
|
477
|
+
// Realpath resolver applied to a settings path entry's resolved
|
|
478
|
+
// directory before comparing it against `skip`. bin/gentle-shell.mjs's
|
|
479
|
+
// --package-root take-over passes `skip.dir` as an already-realpath'd
|
|
480
|
+
// directory; without also realpath'ing the settings entry here, a
|
|
481
|
+
// settings path entry reaching that same physical directory through a
|
|
482
|
+
// symlink is not recognised as the package being taken over and gets
|
|
483
|
+
// re-injected as a second, redundant -e for it
|
|
484
|
+
// (R4-forced-root-symlink-double-injection). Defaults to identity so
|
|
485
|
+
// this function stays pure and existing callers keep comparing raw
|
|
486
|
+
// strings.
|
|
487
|
+
realpath?: (dir: string) => string;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
export interface OtherPackageInjections {
|
|
491
|
+
paths: string[];
|
|
492
|
+
warnings: string[];
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
function entryFilterKeys(entry: unknown): string[] {
|
|
496
|
+
if (entry === null || typeof entry !== "object") return [];
|
|
497
|
+
const record = entry as Record<string, unknown>;
|
|
498
|
+
const keys: string[] = [];
|
|
499
|
+
if ("extensions" in record) keys.push("extensions");
|
|
500
|
+
if ("autoload" in record) keys.push("autoload");
|
|
501
|
+
return keys;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Plans the `-e <dir>` flags a take-over must add for every OTHER settings
|
|
505
|
+
// package once `--no-extensions` drops normal settings-driven extension
|
|
506
|
+
// discovery. git-sourced packages are skipped (their install directory is
|
|
507
|
+
// not derivable without pi's own package manager) with a warning; object
|
|
508
|
+
// entries carrying `extensions`/`autoload` filters are still included, with
|
|
509
|
+
// a warning that the take-over cannot honour those filters (their skills,
|
|
510
|
+
// prompts, and themes still load through ordinary settings discovery, which
|
|
511
|
+
// --no-extensions does not affect).
|
|
512
|
+
export function otherPackageInjections(input: OtherPackageInjectionsInput): OtherPackageInjections {
|
|
513
|
+
const paths: string[] = [];
|
|
514
|
+
const warnings: string[] = [];
|
|
515
|
+
const packages = parseSettingsPackages(input.settingsText);
|
|
516
|
+
if (packages === undefined) return { paths, warnings };
|
|
517
|
+
const isDirectory = input.isDirectory ?? (() => true);
|
|
518
|
+
const realpath = input.realpath ?? ((dir: string) => dir);
|
|
519
|
+
|
|
520
|
+
for (const entry of packages) {
|
|
521
|
+
const source = entrySource(entry);
|
|
522
|
+
if (source === undefined) continue;
|
|
523
|
+
const kind = packageSourceKind(source);
|
|
524
|
+
|
|
525
|
+
// Skip every gentle-pi entry unconditionally, not only the one
|
|
526
|
+
// matching `skip`'s kind: settings can carry more than one gentle-pi
|
|
527
|
+
// declaration (for example an npm:gentle-pi entry alongside the path
|
|
528
|
+
// declaration actually being taken over), and re-injecting any of
|
|
529
|
+
// them as an "other package" would double-load gentle-pi extensions.
|
|
530
|
+
if (kind === "npm" && npmSourceDeclaresGentlePi(source)) continue;
|
|
531
|
+
if (kind === "path") {
|
|
532
|
+
const dir = resolvePath(input.agentDir, source);
|
|
533
|
+
// Compared through realpath on BOTH sides (not the raw resolved
|
|
534
|
+
// strings): skip.dir may already be a realpath itself
|
|
535
|
+
// (bin/gentle-shell.mjs's --package-root take-over) or may not be
|
|
536
|
+
// (a plain settings.json declaration), so only comparing one side
|
|
537
|
+
// through realpath would break whichever case does not match that
|
|
538
|
+
// assumption. Realpath'ing both keeps the exact-match case
|
|
539
|
+
// (skip.dir derived from the very same source) trivially correct
|
|
540
|
+
// while also recognising a settings entry that reaches the same
|
|
541
|
+
// physical directory as skip through a symlink.
|
|
542
|
+
if (input.skip.kind === "path" && realpath(dir) === realpath(input.skip.dir)) continue;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
if (kind === "git") {
|
|
546
|
+
warnings.push(
|
|
547
|
+
`gentle-shell: skipping git-sourced package "${source}" during takeover (its install directory is not derivable without pi's own package manager).`,
|
|
548
|
+
);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
const filters = entryFilterKeys(entry);
|
|
553
|
+
if (filters.length > 0) {
|
|
554
|
+
warnings.push(
|
|
555
|
+
`gentle-shell: package "${source}" has ${filters.join("/")} filters that this takeover cannot honour for extensions; its skills, prompts, and themes still load through settings discovery.`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const dir = kind === "npm" ? join(input.agentDir, "npm", "node_modules", npmPackageName(source)) : resolvePath(input.agentDir, source);
|
|
560
|
+
if (!isDirectory(dir)) {
|
|
561
|
+
warnings.push(`gentle-shell: skipping declared package "${source}": ${dir} is not a directory`);
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
paths.push(dir);
|
|
565
|
+
}
|
|
566
|
+
return { paths, warnings };
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// --- loose extension discovery ----------------------------------------------
|
|
570
|
+
|
|
571
|
+
export interface LooseExtensionFsEntry {
|
|
572
|
+
name: string;
|
|
573
|
+
isFile: boolean;
|
|
574
|
+
isDirectory: boolean;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
export interface LooseExtensionFs {
|
|
578
|
+
// Lists dir's direct children with cheap type info per entry. A throwing
|
|
579
|
+
// readdir (missing or unreadable dir) is treated the same as an empty
|
|
580
|
+
// directory by discoverLooseExtensionEntries.
|
|
581
|
+
readdir: (dir: string) => LooseExtensionFsEntry[];
|
|
582
|
+
// Existence check used only for a child subdirectory's index.ts/index.js.
|
|
583
|
+
exists: (path: string) => boolean;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// scripts/build-runtime-modules.mjs rewrites every occurrence of a dot, the
|
|
587
|
+
// letters ts, and an immediately following closing quote (single or double)
|
|
588
|
+
// to end in mjs instead, when it generates runtime/gentle-shell-launcher.mjs
|
|
589
|
+
// — a plain `.replace(/\.ts(["'])/g, ...)` that cannot tell an import
|
|
590
|
+
// specifier from an ordinary string literal. Any other string ending the
|
|
591
|
+
// same way — a dot, the letters ts, and a closing quote right after — would
|
|
592
|
+
// get silently corrupted into the mjs form in the generated runtime module,
|
|
593
|
+
// so the three constants below are built by concatenation instead of
|
|
594
|
+
// written as literals that would trigger the same rewrite.
|
|
595
|
+
const TS_EXTENSION = `.t${"s"}`;
|
|
596
|
+
const INDEX_TS_FILENAME = `index${TS_EXTENSION}`;
|
|
597
|
+
const DECLARATION_FILE_SUFFIX = `.d${TS_EXTENSION}`;
|
|
598
|
+
const LOOSE_EXTENSION_FILE_PATTERN = /\.(?:ts|js|mjs)$/;
|
|
599
|
+
|
|
600
|
+
function isLooseExtensionFile(name: string): boolean {
|
|
601
|
+
if (name.startsWith(".")) return false;
|
|
602
|
+
if (name.endsWith(DECLARATION_FILE_SUFFIX)) return false;
|
|
603
|
+
return LOOSE_EXTENSION_FILE_PATTERN.test(name);
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
// Mirrors pi's own discoverExtensionsInDir (packages/coding-agent/src/core/
|
|
607
|
+
// extensions/loader.ts): direct *.ts/*.js/*.mjs files, plus <subdir>/index.ts
|
|
608
|
+
// (falling back to <subdir>/index.js) for a child directory that has one. No
|
|
609
|
+
// recursion beyond that one level, matching pi's own rule that a more complex
|
|
610
|
+
// nested package must use a package.json manifest instead.
|
|
611
|
+
//
|
|
612
|
+
// Unlike pi's own scan, hidden entries (dotfiles, and hidden subdirectories)
|
|
613
|
+
// and *.d.ts files are deliberately excluded here: pi's `-e <file>` flag hands
|
|
614
|
+
// the path straight to its module loader with no directory-discovery pass of
|
|
615
|
+
// its own (see buildPiInvocation's takeOver branch), so a hidden file or a
|
|
616
|
+
// type-only declaration file was never a runnable extension and would only
|
|
617
|
+
// surface a confusing "Cannot find module"/empty-module error once injected.
|
|
618
|
+
//
|
|
619
|
+
// Returns already-resolved absolute file paths, sorted by name so the result
|
|
620
|
+
// (and therefore -e ordering) does not depend on the host filesystem's
|
|
621
|
+
// unspecified readdir order.
|
|
622
|
+
export function discoverLooseExtensionEntries(dir: string, fs: LooseExtensionFs): string[] {
|
|
623
|
+
let entries: LooseExtensionFsEntry[];
|
|
624
|
+
try {
|
|
625
|
+
entries = fs.readdir(dir);
|
|
626
|
+
} catch {
|
|
627
|
+
return [];
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const sorted = [...entries].sort((a, b) => a.name.localeCompare(b.name));
|
|
631
|
+
const discovered: string[] = [];
|
|
632
|
+
|
|
633
|
+
for (const entry of sorted) {
|
|
634
|
+
if (entry.name.startsWith(".")) continue;
|
|
635
|
+
|
|
636
|
+
if (entry.isFile) {
|
|
637
|
+
if (isLooseExtensionFile(entry.name)) discovered.push(join(dir, entry.name));
|
|
638
|
+
continue;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (!entry.isDirectory) continue;
|
|
642
|
+
const childDir = join(dir, entry.name);
|
|
643
|
+
const indexTs = join(childDir, INDEX_TS_FILENAME);
|
|
644
|
+
const indexJs = join(childDir, "index.js");
|
|
645
|
+
if (fs.exists(indexTs)) discovered.push(indexTs);
|
|
646
|
+
else if (fs.exists(indexJs)) discovered.push(indexJs);
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return discovered;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// --- pi invocation builder ---------------------------------------------------
|
|
653
|
+
|
|
654
|
+
export interface BuildPiInvocationInput {
|
|
655
|
+
runtime: PiRuntime;
|
|
656
|
+
home: ResolvedHome;
|
|
657
|
+
packageRoot: string;
|
|
658
|
+
declaration: GentlePiDeclaration | undefined;
|
|
659
|
+
// True when the target settings already declare a *different* gentle-pi
|
|
660
|
+
// than this launcher's own packageRoot (or --package-root forces it):
|
|
661
|
+
// the launcher takes over the pi invocation instead of deferring to the
|
|
662
|
+
// declared package.
|
|
663
|
+
takeOver: boolean;
|
|
664
|
+
// Directories for every OTHER settings package, from otherPackageInjections.
|
|
665
|
+
// Only consulted when takeOver is true.
|
|
666
|
+
otherPackagePaths: string[];
|
|
667
|
+
// Already-resolved loose extension FILE paths (never directories) that
|
|
668
|
+
// normal pi discovery would otherwise have picked up from
|
|
669
|
+
// <agentDir>/extensions and the project-local <cwd>/.pi/extensions before
|
|
670
|
+
// --no-extensions drops that discovery — see discoverLooseExtensionEntries.
|
|
671
|
+
// Only consulted when takeOver is true. The caller resolves the actual
|
|
672
|
+
// file list per candidate directory (or, when a candidate directory is
|
|
673
|
+
// itself a self-contained extension — its own index.ts/index.js, or a
|
|
674
|
+
// pi package manifest at its root — passes that directory through
|
|
675
|
+
// unchanged instead, since pi's own module loader resolves that case
|
|
676
|
+
// directly).
|
|
677
|
+
looseExtensionEntries?: string[];
|
|
678
|
+
passthrough: string[];
|
|
679
|
+
// Set when parseLauncherArgs recognised passthrough[0] as one of
|
|
680
|
+
// PI_SUBCOMMANDS. pi dispatches install/remove/uninstall/update/list/
|
|
681
|
+
// config/auth on argv[0] before its own flag parsing, so none of the
|
|
682
|
+
// gentle-pi extension injection below may precede it.
|
|
683
|
+
piSubcommand?: PiSubcommand;
|
|
684
|
+
baseEnv: Record<string, string | undefined>;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
export interface PiInvocation {
|
|
688
|
+
command: string;
|
|
689
|
+
args: string[];
|
|
690
|
+
env: Record<string, string | undefined>;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
function packageRootAssetArgs(packageRoot: string): string[] {
|
|
694
|
+
return ["--theme", join(packageRoot, "themes"), "--skill", join(packageRoot, "skills"), "--prompt-template", join(packageRoot, "prompts")];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function packageRootInjectionArgs(packageRoot: string): string[] {
|
|
698
|
+
return ["-e", packageRoot, ...packageRootAssetArgs(packageRoot)];
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// Four cases, checked in this order — `piSubcommand` first, then `takeOver`:
|
|
702
|
+
// - piSubcommand: pi dispatches install/remove/uninstall/update/list/
|
|
703
|
+
// config/auth on argv[0] before it even parses flags, so any injected
|
|
704
|
+
// -e/--theme/--skill/--prompt-template flag ahead of it stops pi from
|
|
705
|
+
// recognising its subcommand at all — this is exactly the observed
|
|
706
|
+
// 2026-09-22 bug where `gentle-shell install npm:x` opened an
|
|
707
|
+
// interactive pi session instead of running the package manager. No
|
|
708
|
+
// injection of any kind (including a take-over's --no-extensions and
|
|
709
|
+
// other-package/loose-extension -e flags) may precede it.
|
|
710
|
+
// - takeOver: the target settings declare a *different* gentle-pi, or
|
|
711
|
+
// --package-root forced a takeover regardless of any declaration. This
|
|
712
|
+
// must win over the next two cases even when there is no declaration to
|
|
713
|
+
// report, or the plain branch would silently drop --no-extensions and
|
|
714
|
+
// the other-package injections while bin/gentle-shell.mjs still prints
|
|
715
|
+
// the "taking over" message. `--no-extensions` drops normal
|
|
716
|
+
// settings-driven extension discovery, so it is replaced by an explicit
|
|
717
|
+
// `-e <dir>` for every OTHER settings package (skills/prompts/themes
|
|
718
|
+
// for those packages still load through ordinary settings discovery,
|
|
719
|
+
// which --no-extensions does not affect), then an explicit `-e <file>`
|
|
720
|
+
// for every loose extension entry normal discovery would otherwise have
|
|
721
|
+
// found under <agentDir>/extensions and the project-local
|
|
722
|
+
// .pi/extensions, and finally this launcher's own packageRoot injected
|
|
723
|
+
// last so it wins any conflict. Every -e path is injected at most once
|
|
724
|
+
// (R3-001): a loose entry that duplicates an other-package path, or
|
|
725
|
+
// repeats within looseExtensionEntries itself, is skipped rather than
|
|
726
|
+
// loaded twice.
|
|
727
|
+
// - Not takeOver, no declaration: inject this launcher's own packageRoot,
|
|
728
|
+
// exactly as when nothing else in settings loads gentle-pi.
|
|
729
|
+
// - Not takeOver, with a declaration: no injection at all — the target
|
|
730
|
+
// settings already load a gentle-pi the launcher accepts as-is (the
|
|
731
|
+
// `--link` case with a pi-managed install matching this launcher).
|
|
732
|
+
export function buildPiInvocation(input: BuildPiInvocationInput): PiInvocation {
|
|
733
|
+
const args = [...input.runtime.args];
|
|
734
|
+
|
|
735
|
+
if (input.piSubcommand !== undefined) {
|
|
736
|
+
// No injection at all: pi must see the bare subcommand as argv[0].
|
|
737
|
+
} else if (input.takeOver) {
|
|
738
|
+
args.push("--no-extensions");
|
|
739
|
+
const injected = new Set<string>();
|
|
740
|
+
for (const otherPath of input.otherPackagePaths) {
|
|
741
|
+
if (injected.has(otherPath)) continue;
|
|
742
|
+
injected.add(otherPath);
|
|
743
|
+
args.push("-e", otherPath);
|
|
744
|
+
}
|
|
745
|
+
for (const entry of input.looseExtensionEntries ?? []) {
|
|
746
|
+
if (injected.has(entry)) continue;
|
|
747
|
+
injected.add(entry);
|
|
748
|
+
args.push("-e", entry);
|
|
749
|
+
}
|
|
750
|
+
// R3-003: the launcher's own package root must also be checked
|
|
751
|
+
// against the dedupe set instead of being appended unconditionally,
|
|
752
|
+
// or a settings package/loose entry that resolves to the same
|
|
753
|
+
// directory as --package-root would be injected twice.
|
|
754
|
+
if (!injected.has(input.packageRoot)) {
|
|
755
|
+
injected.add(input.packageRoot);
|
|
756
|
+
args.push("-e", input.packageRoot);
|
|
757
|
+
}
|
|
758
|
+
args.push(...packageRootAssetArgs(input.packageRoot));
|
|
759
|
+
} else if (input.declaration === undefined) {
|
|
760
|
+
args.push(...packageRootInjectionArgs(input.packageRoot));
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
args.push(...input.passthrough);
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
command: input.runtime.command,
|
|
767
|
+
args,
|
|
768
|
+
env: { ...input.baseEnv, PI_CODING_AGENT_DIR: input.home.dir, GENTLE_PI_AGENT_HOME: input.home.dir },
|
|
769
|
+
};
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// --- spawn planning ------------------------------------------------------------
|
|
773
|
+
|
|
774
|
+
// R3-001: `findOnPath` can resolve a PATHEXT candidate such as a .CMD or .BAT
|
|
775
|
+
// shim on win32 (exactly how an npm-installed `pi` lands on PATH), and a
|
|
776
|
+
// GENTLE_SHELL_PI override can point at one too. Current Node releases refuse
|
|
777
|
+
// to spawn a batch file directly without `shell: true` (EINVAL), so both the
|
|
778
|
+
// version probe and the real launch route a batch shim through cmd.exe as one
|
|
779
|
+
// quoted command line instead of spawning it directly.
|
|
780
|
+
const CMD_EXE_SPECIAL_CHARS = /[\s"&|<>^%()]/;
|
|
781
|
+
|
|
782
|
+
// cmd.exe quoting is deliberately simple, not a full cmd.exe parser: wrap a
|
|
783
|
+
// token in double quotes when it is empty or contains whitespace or any of
|
|
784
|
+
// `"&|<>^%()`, and escape an inner `"` as `\"` — doubling inner quotes is not
|
|
785
|
+
// reliable in cmd.exe, unlike the `\"` convention Node's own Windows spawn
|
|
786
|
+
// helpers use.
|
|
787
|
+
export function quoteForCmdExe(token: string): string {
|
|
788
|
+
if (token.length > 0 && !CMD_EXE_SPECIAL_CHARS.test(token)) return token;
|
|
789
|
+
return `"${token.replace(/"/g, '\\"')}"`;
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
export interface PlanSpawnInput {
|
|
793
|
+
command: string;
|
|
794
|
+
args: string[];
|
|
795
|
+
platform: NodeJS.Platform;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
export interface SpawnPlan {
|
|
799
|
+
command: string;
|
|
800
|
+
args: string[];
|
|
801
|
+
shell: boolean;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
export function planSpawn(input: PlanSpawnInput): SpawnPlan {
|
|
805
|
+
const { command, args, platform } = input;
|
|
806
|
+
if (platform === "win32" && /\.(cmd|bat)$/i.test(command)) {
|
|
807
|
+
return { command: [command, ...args].map(quoteForCmdExe).join(" "), args: [], shell: true };
|
|
808
|
+
}
|
|
809
|
+
return { command, args, shell: false };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// --- reporting ---------------------------------------------------------------
|
|
813
|
+
|
|
814
|
+
export interface DescribeVersionInput {
|
|
815
|
+
gentlePiVersion: string;
|
|
816
|
+
piVersion: string | undefined;
|
|
817
|
+
home: ResolvedHome;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
export function describeVersion(input: DescribeVersionInput): string {
|
|
821
|
+
return [
|
|
822
|
+
`gentle-shell ${input.gentlePiVersion}`,
|
|
823
|
+
`pi ${input.piVersion ?? "not found"}`,
|
|
824
|
+
`home ${input.home.mode} ${input.home.dir}`,
|
|
825
|
+
].join("\n");
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
export function helpText(): string {
|
|
829
|
+
return [
|
|
830
|
+
"Usage: gentle-shell [options] [-- pi-args...]",
|
|
831
|
+
" gentle-shell home [link|isolated|<path>]",
|
|
832
|
+
"",
|
|
833
|
+
"Opens pi with the Gentle Shell package loaded, without touching your",
|
|
834
|
+
"vanilla pi installation.",
|
|
835
|
+
"",
|
|
836
|
+
"Options:",
|
|
837
|
+
" --link Use your existing pi agent home (never edits its settings.json).",
|
|
838
|
+
" --isolated Use the dedicated ~/.gentle-shell/agent home (default).",
|
|
839
|
+
" --home <path> Use a custom agent home directory.",
|
|
840
|
+
" --package-root <dir> Force this directory as the gentle-pi package to load, taking over",
|
|
841
|
+
" from any conflicting package the target settings.json already declares.",
|
|
842
|
+
" --help, -h Show this help text.",
|
|
843
|
+
" --version Show gentle-shell, pi, and home version information.",
|
|
844
|
+
"",
|
|
845
|
+
"Commands:",
|
|
846
|
+
" home Print or persist the effective home mode (link, isolated, or a path).",
|
|
847
|
+
"",
|
|
848
|
+
"Managing packages:",
|
|
849
|
+
" gentle-shell install npm:<pkg> Run pi's own 'install' against the resolved home.",
|
|
850
|
+
" gentle-shell remove <source> Run pi's own 'remove' against the resolved home.",
|
|
851
|
+
" gentle-shell list Run pi's own 'list' against the resolved home.",
|
|
852
|
+
" gentle-shell update [target] Run pi's own 'update' against the resolved home.",
|
|
853
|
+
" gentle-shell config Run pi's own 'config' against the resolved home.",
|
|
854
|
+
" gentle-shell auth <command> Run pi's own 'auth' against the resolved home.",
|
|
855
|
+
" These run pi's own commands, forwarded verbatim, against the --isolated home",
|
|
856
|
+
" (or your own pi home with --link). Running 'gentle-shell install npm:gentle-pi'",
|
|
857
|
+
" inside the isolated home is unnecessary: gentle-shell already loads the",
|
|
858
|
+
" package itself.",
|
|
859
|
+
"",
|
|
860
|
+
"Environment variables:",
|
|
861
|
+
" GENTLE_SHELL_PI Path to the pi executable to run.",
|
|
862
|
+
" GENTLE_SHELL_HOME Directory for the isolated home (default: ~/.gentle-shell/agent).",
|
|
863
|
+
" PI_CODING_AGENT_DIR Directory for the --link home, shared with pi itself.",
|
|
864
|
+
"",
|
|
865
|
+
"Every other argument is forwarded to pi unchanged.",
|
|
866
|
+
].join("\n");
|
|
867
|
+
}
|