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,99 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
|
|
8
|
+
// isPiManagedInstall gates the POSTINSTALL entry point only (scripts/install-gentle-ai.mjs):
|
|
9
|
+
// it decides whether the running gentle-pi package sits under a directory
|
|
10
|
+
// pattern Pi's own package manager creates (npm-backed or git-backed), so an
|
|
11
|
+
// `npm install -g gentle-pi`, a plain development git checkout, or an npx
|
|
12
|
+
// cache directory never writes to the user's global Pi settings. It is
|
|
13
|
+
// deliberately unrelated to installTuiModeSetting's own ownership check
|
|
14
|
+
// (which compares the package location against one specific resolved agent
|
|
15
|
+
// home) and to installIsolatedTuiModeSetting (gentle-shell's own bootstrap of
|
|
16
|
+
// a directory it just created), covered in the other two test files.
|
|
17
|
+
|
|
18
|
+
const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
|
|
19
|
+
const { isPiManagedInstall } = await import(helperUrl.href);
|
|
20
|
+
|
|
21
|
+
test("isPiManagedInstall recognizes the user-scope npm-managed layout", () => {
|
|
22
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "npm", "node_modules", "gentle-pi")), true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test("isPiManagedInstall recognizes the project-scope npm-managed layout", () => {
|
|
26
|
+
assert.equal(isPiManagedInstall(join("/repo", ".pi", "npm", "node_modules", "gentle-pi")), true);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("isPiManagedInstall recognizes Pi's git-managed layout", () => {
|
|
30
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".pi", "agent", "git", "github.com", "Gentleman-Programming", "gentle-pi")), true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("isPiManagedInstall rejects a global npm -g install", () => {
|
|
34
|
+
assert.equal(isPiManagedInstall("/usr/local/lib/node_modules/gentle-pi"), false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("isPiManagedInstall rejects a plain development git checkout", () => {
|
|
38
|
+
assert.equal(isPiManagedInstall("/Users/alan/work/gentle-pi"), false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("isPiManagedInstall rejects an npx cache directory", () => {
|
|
42
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".npm", "_npx", "abc123", "node_modules", "gentle-pi")), false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("isPiManagedInstall rejects a pnpm content-addressable store", () => {
|
|
46
|
+
assert.equal(isPiManagedInstall(join("/home/alan", ".pnpm", "gentle-pi@1.0.0", "node_modules", "gentle-pi")), false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("isPiManagedInstall resolves a relative path before checking", () => {
|
|
50
|
+
const cwd = process.cwd();
|
|
51
|
+
try {
|
|
52
|
+
process.chdir(join("/tmp"));
|
|
53
|
+
assert.equal(isPiManagedInstall(join(".", "npm", "node_modules", "gentle-pi")), true);
|
|
54
|
+
} finally {
|
|
55
|
+
process.chdir(cwd);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// --- postinstall entry integration --------------------------------------------
|
|
60
|
+
|
|
61
|
+
function fixture(t: test.TestContext, packageSegments: readonly string[]) {
|
|
62
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-guard-"));
|
|
63
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
64
|
+
const home = join(root, "agent");
|
|
65
|
+
const packageRoot = join(home, ...packageSegments);
|
|
66
|
+
const scripts = join(packageRoot, "scripts");
|
|
67
|
+
mkdirSync(scripts, { recursive: true });
|
|
68
|
+
copyFileSync(helperUrl, join(scripts, "install-tui-mode-setting.mjs"));
|
|
69
|
+
copyFileSync(new URL("../scripts/install-gentle-ai.mjs", import.meta.url), join(scripts, "install-gentle-ai.mjs"));
|
|
70
|
+
writeFileSync(
|
|
71
|
+
join(scripts, "gentle-ai-installer.mjs"),
|
|
72
|
+
'export const INSTALLER_VERSION = "test"; export async function installGentleAi() { return { installed: true, binaryPath: "fixture" }; }',
|
|
73
|
+
);
|
|
74
|
+
const settings = join(home, "settings.json");
|
|
75
|
+
const env = { ...process.env, GENTLE_PI_AGENT_HOME: home, GENTLE_PI_SKIP_GENTLE_AI_INSTALL: "0" };
|
|
76
|
+
return { root, home, packageRoot, scripts, settings, env };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test("postinstall skips the global settings write outside a pi-managed install", (t) => {
|
|
80
|
+
const f = fixture(t, ["node_modules", "gentle-pi"]);
|
|
81
|
+
const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
|
|
82
|
+
assert.equal(result.status, 0, result.stderr);
|
|
83
|
+
assert.match(`${result.stdout}${result.stderr}`, /skip/i);
|
|
84
|
+
assert.equal(existsSync(f.settings), false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("postinstall still writes fullscreen for a pi-managed npm install", (t) => {
|
|
88
|
+
const f = fixture(t, ["npm", "node_modules", "gentle-pi"]);
|
|
89
|
+
const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
|
|
90
|
+
assert.equal(result.status, 0, result.stderr);
|
|
91
|
+
assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("postinstall still writes fullscreen for a pi-managed git install", (t) => {
|
|
95
|
+
const f = fixture(t, ["git", "github.com", "Gentleman-Programming", "gentle-pi"]);
|
|
96
|
+
const result = spawnSync(process.execPath, [join(f.scripts, "install-gentle-ai.mjs")], { encoding: "utf8", env: f.env });
|
|
97
|
+
assert.equal(result.status, 0, result.stderr);
|
|
98
|
+
assert.equal(JSON.parse(readFileSync(f.settings, "utf8")).tuiMode, "fullscreen");
|
|
99
|
+
});
|
|
@@ -7,7 +7,7 @@ import { join } from "node:path";
|
|
|
7
7
|
import { test } from "node:test";
|
|
8
8
|
|
|
9
9
|
const helperUrl = new URL("../scripts/install-tui-mode-setting.mjs", import.meta.url);
|
|
10
|
-
const { installTuiModeSetting } = await import(helperUrl.href);
|
|
10
|
+
const { installTuiModeSetting, installIsolatedTuiModeSetting } = await import(helperUrl.href);
|
|
11
11
|
|
|
12
12
|
function fixture(t: { after(fn: () => void): void }, packagePath: readonly string[] = ["npm", "node_modules", "gentle-pi"]) {
|
|
13
13
|
const root = mkdtempSync(join(tmpdir(), "gentle-tui-test-"));
|
|
@@ -269,6 +269,44 @@ for (const fault of ["write", "rename", "concurrent-change"]) {
|
|
|
269
269
|
});
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
// --- installIsolatedTuiModeSetting -------------------------------------------
|
|
273
|
+
// gentle-shell's own isolated-home bootstrap (T2): unlike installTuiModeSetting,
|
|
274
|
+
// there is no "physically installed under this home's npm/node_modules"
|
|
275
|
+
// ownership check to satisfy, because gentle-shell just created `dir` itself
|
|
276
|
+
// moments ago; a plain agent-home-shaped temp directory is enough.
|
|
277
|
+
|
|
278
|
+
test("installIsolatedTuiModeSetting writes fullscreen into a freshly created directory", async (t) => {
|
|
279
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
280
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
281
|
+
const dir = join(root, "gentle-shell", "agent");
|
|
282
|
+
mkdirSync(dir, { recursive: true });
|
|
283
|
+
assert.deepEqual(await installIsolatedTuiModeSetting(dir), { changed: true, recognized: true });
|
|
284
|
+
assert.deepEqual(JSON.parse(readFileSync(join(dir, "settings.json"), "utf8")), { tuiMode: "fullscreen" });
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("installIsolatedTuiModeSetting preserves other settings fields already present", async (t) => {
|
|
288
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
289
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
290
|
+
writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "regular", theme: "rose" }));
|
|
291
|
+
assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: true, recognized: true });
|
|
292
|
+
assert.deepEqual(JSON.parse(readFileSync(join(root, "settings.json"), "utf8")), { tuiMode: "fullscreen", theme: "rose" });
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
test("installIsolatedTuiModeSetting is a no-op when already fullscreen", async (t) => {
|
|
296
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
297
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
298
|
+
writeFileSync(join(root, "settings.json"), JSON.stringify({ tuiMode: "fullscreen" }));
|
|
299
|
+
const before = lstatSync(join(root, "settings.json"));
|
|
300
|
+
assert.deepEqual(await installIsolatedTuiModeSetting(root), { changed: false, recognized: true });
|
|
301
|
+
assert.equal(lstatSync(join(root, "settings.json")).ino, before.ino);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("installIsolatedTuiModeSetting rejects a missing directory", async (t) => {
|
|
305
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-shell-isolated-"));
|
|
306
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
307
|
+
await assert.rejects(installIsolatedTuiModeSetting(join(root, "absent")));
|
|
308
|
+
});
|
|
309
|
+
|
|
272
310
|
type PostinstallRoute = "skip" | "success" | "failure";
|
|
273
311
|
|
|
274
312
|
function writePostinstallFixture(f: ReturnType<typeof fixture>, route: PostinstallRoute) {
|
|
@@ -1563,9 +1563,9 @@ test("pi-pretty wrapper uses cached ESM loading for compiled and pnpm symlink in
|
|
|
1563
1563
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1564
1564
|
});
|
|
1565
1565
|
|
|
1566
|
-
test("Gentle Shell v3.
|
|
1566
|
+
test("Gentle Shell v3.5.0 package and runtime stop before publication", () => {
|
|
1567
1567
|
const packageJson = readPackageJson();
|
|
1568
|
-
assert.equal(packageJson.version, "3.
|
|
1568
|
+
assert.equal(packageJson.version, "3.5.0", "the release manifest must remain explicitly pinned to v3.5.0");
|
|
1569
1569
|
assert.equal(
|
|
1570
1570
|
packageJson.scripts?.test,
|
|
1571
1571
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
INTERACTIVE_HOST_ENV,
|
|
5
|
+
isInteractiveMode,
|
|
6
|
+
isInteractiveRpcHost,
|
|
7
|
+
withoutInteractiveHost,
|
|
8
|
+
} from "../lib/rpc-host.ts";
|
|
9
|
+
|
|
10
|
+
// Interactive host signal: the desktop app sets GENTLE_SHELL_INTERACTIVE_HOST=1
|
|
11
|
+
// on the pi process it spawns directly (`--mode rpc`). Subagent children
|
|
12
|
+
// spawned by lib/agents-runner.ts must never see it, so rpc without the
|
|
13
|
+
// variable stays byte-identical to today's headless behaviour.
|
|
14
|
+
|
|
15
|
+
test("isInteractiveRpcHost is true only for rpc mode with the variable set to \"1\"", () => {
|
|
16
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("isInteractiveRpcHost is false for rpc mode without the variable", () => {
|
|
20
|
+
assert.equal(isInteractiveRpcHost("rpc", {}), false);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("isInteractiveRpcHost is false for rpc mode when the variable is not exactly \"1\"", () => {
|
|
24
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "true" }), false);
|
|
25
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "0" }), false);
|
|
26
|
+
assert.equal(isInteractiveRpcHost("rpc", { [INTERACTIVE_HOST_ENV]: "" }), false);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("isInteractiveRpcHost is false for every non-rpc mode even with the variable set", () => {
|
|
30
|
+
for (const mode of ["tui", "print", "json"]) {
|
|
31
|
+
assert.equal(isInteractiveRpcHost(mode, { [INTERACTIVE_HOST_ENV]: "1" }), false, `mode ${mode}`);
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("isInteractiveRpcHost defaults to process.env when no env is supplied", (t) => {
|
|
36
|
+
const previous = process.env[INTERACTIVE_HOST_ENV];
|
|
37
|
+
process.env[INTERACTIVE_HOST_ENV] = "1";
|
|
38
|
+
t.after(() => {
|
|
39
|
+
if (previous === undefined) delete process.env[INTERACTIVE_HOST_ENV];
|
|
40
|
+
else process.env[INTERACTIVE_HOST_ENV] = previous;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(isInteractiveRpcHost("rpc"), true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("isInteractiveMode is true for tui regardless of the variable", () => {
|
|
47
|
+
assert.equal(isInteractiveMode("tui", {}), true);
|
|
48
|
+
assert.equal(isInteractiveMode("tui", { [INTERACTIVE_HOST_ENV]: "1" }), true);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("isInteractiveMode is true for an interactive rpc host", () => {
|
|
52
|
+
assert.equal(isInteractiveMode("rpc", { [INTERACTIVE_HOST_ENV]: "1" }), true);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("isInteractiveMode is false for rpc without the variable, and for print/json", () => {
|
|
56
|
+
assert.equal(isInteractiveMode("rpc", {}), false);
|
|
57
|
+
assert.equal(isInteractiveMode("print", { [INTERACTIVE_HOST_ENV]: "1" }), false);
|
|
58
|
+
assert.equal(isInteractiveMode("json", { [INTERACTIVE_HOST_ENV]: "1" }), false);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("withoutInteractiveHost returns a copy without the variable, preserving the rest", () => {
|
|
62
|
+
const source = { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" };
|
|
63
|
+
|
|
64
|
+
const result = withoutInteractiveHost(source);
|
|
65
|
+
|
|
66
|
+
assert.deepEqual(result, { PATH: "/bin", KEEP: "yes" });
|
|
67
|
+
assert.deepEqual(source, { PATH: "/bin", [INTERACTIVE_HOST_ENV]: "1", KEEP: "yes" }, "the source env is not mutated");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("withoutInteractiveHost is a no-op copy when the variable is absent", () => {
|
|
71
|
+
const source = { PATH: "/bin" };
|
|
72
|
+
|
|
73
|
+
const result = withoutInteractiveHost(source);
|
|
74
|
+
|
|
75
|
+
assert.deepEqual(result, { PATH: "/bin" });
|
|
76
|
+
assert.notEqual(result, source, "a fresh object is returned, not the same reference");
|
|
77
|
+
});
|