xtrm-cli 0.5.0 → 0.5.27
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/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
- package/dist/index.cjs +969 -1059
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/clean.ts +7 -6
- package/src/commands/debug.ts +255 -0
- package/src/commands/docs.ts +180 -0
- package/src/commands/help.ts +92 -171
- package/src/commands/init.ts +9 -32
- package/src/commands/install-pi.ts +9 -16
- package/src/commands/install.ts +150 -2
- package/src/commands/pi-install.ts +10 -44
- package/src/core/context.ts +4 -52
- package/src/core/diff.ts +3 -16
- package/src/core/preflight.ts +0 -1
- package/src/index.ts +7 -4
- package/src/types/config.ts +0 -2
- package/src/utils/config-injector.ts +3 -3
- package/src/utils/pi-extensions.ts +41 -0
- package/src/utils/worktree-session.ts +86 -50
- package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
- package/test/extensions/beads-parity.test.ts +94 -0
- package/test/extensions/extension-harness.ts +5 -5
- package/test/extensions/quality-gates-parity.test.ts +89 -0
- package/test/extensions/session-flow.test.ts +91 -0
- package/test/extensions/xtrm-loader.test.ts +38 -20
- package/test/install-pi.test.ts +22 -11
- package/test/pi-extensions.test.ts +50 -0
- package/test/session-launcher.test.ts +28 -38
- package/extensions/beads.ts +0 -109
- package/extensions/core/adapter.ts +0 -45
- package/extensions/core/lib.ts +0 -3
- package/extensions/core/logger.ts +0 -45
- package/extensions/core/runner.ts +0 -71
- package/extensions/custom-footer.ts +0 -160
- package/extensions/main-guard-post-push.ts +0 -44
- package/extensions/main-guard.ts +0 -126
- package/extensions/minimal-mode.ts +0 -201
- package/extensions/quality-gates.ts +0 -67
- package/extensions/service-skills.ts +0 -150
- package/extensions/xtrm-loader.ts +0 -89
- package/hooks/gitnexus-impact-reminder.py +0 -13
- package/src/commands/finish.ts +0 -25
- package/src/core/session-state.ts +0 -139
- package/src/core/xtrm-finish.ts +0 -267
- package/src/tests/session-flow-parity.test.ts +0 -118
- package/src/tests/session-state.test.ts +0 -124
- package/src/tests/xtrm-finish.test.ts +0 -148
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import beadsExtension from "../../../config/pi/extensions/beads/index";
|
|
4
|
+
import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
8
|
+
isToolCallEventType: (name: string, event: any) => event?.toolName === name,
|
|
9
|
+
isBashToolResult: (event: any) => event?.toolName === "bash",
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock("../../../config/pi/extensions/core/lib", async () => {
|
|
13
|
+
const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
|
|
14
|
+
return {
|
|
15
|
+
...actual,
|
|
16
|
+
SubprocessRunner: {
|
|
17
|
+
run: vi.fn(),
|
|
18
|
+
},
|
|
19
|
+
EventAdapter: {
|
|
20
|
+
isBeadsProject: vi.fn(() => true),
|
|
21
|
+
isMutatingFileTool: vi.fn((event: any) => event?.toolName === "write"),
|
|
22
|
+
parseBdCounts: vi.fn(() => ({ open: 1, inProgress: 0 })),
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
vi.mock("node:fs", () => ({
|
|
28
|
+
existsSync: vi.fn(() => false),
|
|
29
|
+
unlinkSync: vi.fn(),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
describe("Pi beads extension parity", () => {
|
|
33
|
+
let harness: ExtensionHarness;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.resetAllMocks();
|
|
37
|
+
harness = new ExtensionHarness();
|
|
38
|
+
harness.pi.sendUserMessage = vi.fn();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("stores closed-this-session marker on successful bd close", async () => {
|
|
42
|
+
const calls: string[][] = [];
|
|
43
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
44
|
+
calls.push(args);
|
|
45
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
beadsExtension(harness.pi);
|
|
49
|
+
|
|
50
|
+
const result = await harness.emit("tool_result", {
|
|
51
|
+
toolName: "bash",
|
|
52
|
+
input: { command: "bd close xtrm-777 --reason done" },
|
|
53
|
+
content: [{ type: "text", text: "closed" }],
|
|
54
|
+
isError: false,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
expect(calls.some((a) => a[0] === "kv" && a[1] === "set" && a[2].startsWith("closed-this-session:"))).toBe(true);
|
|
58
|
+
expect(result?.content?.[1]?.text).toContain("Beads Insight");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("fires memory gate once per closed marker and does not loop", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
63
|
+
if (args[0] === "kv" && args[1] === "get" && `${args[2]}`.startsWith("closed-this-session:")) {
|
|
64
|
+
return { code: 0, stdout: "xtrm-123\n", stderr: "" };
|
|
65
|
+
}
|
|
66
|
+
return { code: 1, stdout: "", stderr: "" };
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
beadsExtension(harness.pi);
|
|
70
|
+
|
|
71
|
+
await harness.emit("agent_end", { messages: [] });
|
|
72
|
+
await harness.emit("agent_end", { messages: [] });
|
|
73
|
+
|
|
74
|
+
expect(harness.pi.sendUserMessage).toHaveBeenCalledTimes(1);
|
|
75
|
+
expect(harness.pi.sendUserMessage).toHaveBeenCalledWith(expect.stringContaining("claim `xtrm-123` was closed this session"));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("consumes .memory-gate-done marker and clears session markers", async () => {
|
|
79
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
80
|
+
const calls: string[][] = [];
|
|
81
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
82
|
+
calls.push(args);
|
|
83
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
beadsExtension(harness.pi);
|
|
87
|
+
await harness.emit("agent_end", { messages: [] });
|
|
88
|
+
|
|
89
|
+
expect(fs.unlinkSync).toHaveBeenCalled();
|
|
90
|
+
expect(calls.some((a) => a[0] === "kv" && a[1] === "clear" && `${a[2]}`.startsWith("claimed:"))).toBe(true);
|
|
91
|
+
expect(calls.some((a) => a[0] === "kv" && a[1] === "clear" && `${a[2]}`.startsWith("closed-this-session:"))).toBe(true);
|
|
92
|
+
expect(harness.pi.sendUserMessage).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -71,14 +71,14 @@ export class ExtensionHarness {
|
|
|
71
71
|
|
|
72
72
|
async emit(event: string, data: any) {
|
|
73
73
|
if (this.handlers[event]) {
|
|
74
|
-
|
|
74
|
+
let lastResult: any = undefined;
|
|
75
75
|
for (const handler of this.handlers[event]) {
|
|
76
|
-
|
|
76
|
+
const res = await handler(data, this.ctx);
|
|
77
77
|
if (res !== undefined) {
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
lastResult = res;
|
|
79
|
+
}
|
|
80
80
|
}
|
|
81
|
-
|
|
81
|
+
return lastResult;
|
|
82
82
|
}
|
|
83
83
|
return undefined;
|
|
84
84
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import qualityGatesExtension from "../../../config/pi/extensions/quality-gates/index";
|
|
4
|
+
import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../../config/pi/extensions/core/lib", async () => {
|
|
8
|
+
const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
SubprocessRunner: {
|
|
12
|
+
run: vi.fn(),
|
|
13
|
+
},
|
|
14
|
+
EventAdapter: {
|
|
15
|
+
isMutatingFileTool: vi.fn((event: any) => event.toolName === "write" || event.toolName === "edit"),
|
|
16
|
+
extractPathFromToolInput: vi.fn((event: any) => event.input.path),
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("node:fs", () => ({
|
|
22
|
+
existsSync: vi.fn(() => true),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe("Pi quality-gates extension parity", () => {
|
|
26
|
+
let harness: ExtensionHarness;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.resetAllMocks();
|
|
30
|
+
harness = new ExtensionHarness();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("runs JS gate for .cjs files and reports stdout details", async () => {
|
|
34
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
35
|
+
code: 0,
|
|
36
|
+
stdout: "ESLint auto-fixed issues",
|
|
37
|
+
stderr: "",
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
qualityGatesExtension(harness.pi);
|
|
41
|
+
|
|
42
|
+
const result = await harness.emit("tool_result", {
|
|
43
|
+
toolName: "write",
|
|
44
|
+
input: { path: "hooks/sample.cjs" },
|
|
45
|
+
content: [{ type: "text", text: "ok" }],
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
expect(SubprocessRunner.run).toHaveBeenCalledWith(
|
|
49
|
+
"node",
|
|
50
|
+
expect.arrayContaining([expect.stringContaining("quality-check.cjs")]),
|
|
51
|
+
expect.any(Object),
|
|
52
|
+
);
|
|
53
|
+
expect(result?.content?.[1]?.text).toContain("ESLint auto-fixed issues");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("fails tool result when hook exits with status 2", async () => {
|
|
57
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
58
|
+
code: 2,
|
|
59
|
+
stdout: "",
|
|
60
|
+
stderr: "Compilation failed: error TS1234",
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
qualityGatesExtension(harness.pi);
|
|
64
|
+
|
|
65
|
+
const result = await harness.emit("tool_result", {
|
|
66
|
+
toolName: "write",
|
|
67
|
+
input: { path: "src/main.ts" },
|
|
68
|
+
content: [{ type: "text", text: "Original content" }],
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result?.isError).toBe(true);
|
|
72
|
+
expect(result?.content?.[1]?.text).toContain("Compilation failed");
|
|
73
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledWith("Quality Gate failed for main.ts", "error");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("no-ops when hook script is missing", async () => {
|
|
77
|
+
(fs.existsSync as any).mockReturnValue(false);
|
|
78
|
+
qualityGatesExtension(harness.pi);
|
|
79
|
+
|
|
80
|
+
const result = await harness.emit("tool_result", {
|
|
81
|
+
toolName: "write",
|
|
82
|
+
input: { path: "src/main.ts" },
|
|
83
|
+
content: [{ type: "text", text: "Original content" }],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(result).toBeUndefined();
|
|
87
|
+
expect(SubprocessRunner.run).not.toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import sessionFlowExtension from "../../../config/pi/extensions/session-flow/index";
|
|
4
|
+
import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
|
|
5
|
+
|
|
6
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
7
|
+
isBashToolResult: (event: any) => event?.toolName === "bash",
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock("../../../config/pi/extensions/core/lib", async () => {
|
|
11
|
+
const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
|
|
12
|
+
return {
|
|
13
|
+
...actual,
|
|
14
|
+
SubprocessRunner: {
|
|
15
|
+
run: vi.fn(),
|
|
16
|
+
},
|
|
17
|
+
EventAdapter: {
|
|
18
|
+
isBeadsProject: vi.fn(() => true),
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("Pi session-flow extension", () => {
|
|
24
|
+
let harness: ExtensionHarness;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.resetAllMocks();
|
|
28
|
+
harness = new ExtensionHarness();
|
|
29
|
+
harness.pi.sendUserMessage = vi.fn();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("adds claim-sync context on bd update --claim", async () => {
|
|
33
|
+
sessionFlowExtension(harness.pi);
|
|
34
|
+
|
|
35
|
+
const result = await harness.emit("tool_result", {
|
|
36
|
+
toolName: "bash",
|
|
37
|
+
input: { command: "bd update xtrm-123 --claim" },
|
|
38
|
+
content: [{ type: "text", text: "ok" }],
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(result?.content?.[1]?.text).toContain("claimed xtrm-123");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("does not trigger follow-up turns on agent_end (prevents stop-loop)", async () => {
|
|
45
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
46
|
+
if (args[0] === "kv" && args[1] === "get") {
|
|
47
|
+
return { code: 0, stdout: "xtrm-123\n", stderr: "" };
|
|
48
|
+
}
|
|
49
|
+
if (args[0] === "show") {
|
|
50
|
+
return { code: 0, stdout: JSON.stringify({ id: "xtrm-123", status: "in_progress" }), stderr: "" };
|
|
51
|
+
}
|
|
52
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
sessionFlowExtension(harness.pi);
|
|
56
|
+
|
|
57
|
+
await harness.emit("agent_end", { messages: [] });
|
|
58
|
+
await harness.emit("agent_end", { messages: [] });
|
|
59
|
+
|
|
60
|
+
expect(harness.pi.sendUserMessage).not.toHaveBeenCalled();
|
|
61
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledTimes(1);
|
|
62
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledWith(
|
|
63
|
+
expect.stringContaining("bd close xtrm-123"),
|
|
64
|
+
"warning",
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reminds about xt end only once per worktree", async () => {
|
|
69
|
+
harness.ctx.cwd = "/repo/.xtrm/worktrees/demo";
|
|
70
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
71
|
+
if (args[0] === "kv" && args[1] === "get") {
|
|
72
|
+
return { code: 0, stdout: "xtrm-999\n", stderr: "" };
|
|
73
|
+
}
|
|
74
|
+
if (args[0] === "show") {
|
|
75
|
+
return { code: 0, stdout: JSON.stringify({ id: "xtrm-999", status: "closed" }), stderr: "" };
|
|
76
|
+
}
|
|
77
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
sessionFlowExtension(harness.pi);
|
|
81
|
+
|
|
82
|
+
await harness.emit("agent_end", { messages: [] });
|
|
83
|
+
await harness.emit("agent_end", { messages: [] });
|
|
84
|
+
|
|
85
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledWith(
|
|
87
|
+
"Run `xt end` to create a PR and clean up this worktree.",
|
|
88
|
+
"info",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
-
import xtrmLoaderExtension from "
|
|
3
|
+
import xtrmLoaderExtension from "../../../config/pi/extensions/xtrm-loader/index";
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
|
|
6
|
+
vi.mock("node:os", () => ({
|
|
7
|
+
homedir: () => "/home/test",
|
|
8
|
+
}));
|
|
9
|
+
|
|
6
10
|
vi.mock("node:fs", () => ({
|
|
7
11
|
existsSync: vi.fn(),
|
|
8
12
|
readFileSync: vi.fn(),
|
|
9
13
|
readdirSync: vi.fn(),
|
|
14
|
+
promises: {
|
|
15
|
+
readFile: vi.fn(),
|
|
16
|
+
},
|
|
10
17
|
}));
|
|
11
18
|
|
|
12
19
|
describe("XTRM Loader Extension", () => {
|
|
@@ -14,40 +21,51 @@ describe("XTRM Loader Extension", () => {
|
|
|
14
21
|
|
|
15
22
|
beforeEach(() => {
|
|
16
23
|
vi.resetAllMocks();
|
|
17
|
-
harness = new ExtensionHarness();
|
|
24
|
+
harness = new ExtensionHarness("/workspace/project");
|
|
25
|
+
(fs.readdirSync as any).mockReturnValue([]);
|
|
18
26
|
});
|
|
19
27
|
|
|
20
|
-
it("
|
|
28
|
+
it("injects using-xtrm content into system prompt at before_agent_start", async () => {
|
|
21
29
|
(fs.existsSync as any).mockImplementation((p: string) => {
|
|
22
|
-
if (p
|
|
23
|
-
if (p.endsWith(".
|
|
30
|
+
if (p === "/home/test/.agents/skills/using-xtrm/SKILL.md") return true;
|
|
31
|
+
if (p.endsWith("ROADMAP.md")) return false;
|
|
32
|
+
if (p.endsWith(".claude/rules")) return false;
|
|
24
33
|
if (p.endsWith(".claude/skills")) return false;
|
|
25
34
|
return false;
|
|
26
35
|
});
|
|
27
36
|
|
|
28
37
|
(fs.readFileSync as any).mockImplementation((p: string) => {
|
|
29
|
-
if (p
|
|
30
|
-
|
|
38
|
+
if (p === "/home/test/.agents/skills/using-xtrm/SKILL.md") {
|
|
39
|
+
return "---\nname: using-xtrm\n---\n# Manual\nUse bd prime";
|
|
40
|
+
}
|
|
31
41
|
return "";
|
|
32
42
|
});
|
|
33
43
|
|
|
34
|
-
(fs.readdirSync as any).mockImplementation((p: string) => {
|
|
35
|
-
if (p.endsWith(".claude/rules")) return [{ name: "rule1.md", isFile: () => true, isDirectory: () => false }];
|
|
36
|
-
return [];
|
|
37
|
-
});
|
|
38
|
-
|
|
39
44
|
xtrmLoaderExtension(harness.pi);
|
|
40
|
-
|
|
41
|
-
// Trigger session_start to load data
|
|
42
45
|
await harness.emit("session_start", {});
|
|
46
|
+
const result = await harness.emit("before_agent_start", { systemPrompt: "Base prompt" });
|
|
47
|
+
|
|
48
|
+
expect(result?.systemPrompt).toContain("XTRM Session Operating Manual");
|
|
49
|
+
expect(result?.systemPrompt).toContain("Use bd prime");
|
|
50
|
+
expect(result?.systemPrompt).not.toContain("name: using-xtrm");
|
|
51
|
+
});
|
|
43
52
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
it("falls back to ~/.pi/agent/skills when ~/.agents path is missing", async () => {
|
|
54
|
+
(fs.existsSync as any).mockImplementation((p: string) => {
|
|
55
|
+
if (p === "/home/test/.agents/skills/using-xtrm/SKILL.md") return false;
|
|
56
|
+
if (p === "/home/test/.pi/agent/skills/using-xtrm/SKILL.md") return true;
|
|
57
|
+
if (p.endsWith("ROADMAP.md")) return false;
|
|
58
|
+
if (p.endsWith(".claude/rules")) return false;
|
|
59
|
+
if (p.endsWith(".claude/skills")) return false;
|
|
60
|
+
return false;
|
|
47
61
|
});
|
|
48
62
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
63
|
+
(fs.readFileSync as any).mockReturnValue("# Manual\nPi fallback path");
|
|
64
|
+
|
|
65
|
+
xtrmLoaderExtension(harness.pi);
|
|
66
|
+
await harness.emit("session_start", {});
|
|
67
|
+
const result = await harness.emit("before_agent_start", { systemPrompt: "Base" });
|
|
68
|
+
|
|
69
|
+
expect(result?.systemPrompt).toContain("Pi fallback path");
|
|
52
70
|
});
|
|
53
71
|
});
|
package/test/install-pi.test.ts
CHANGED
|
@@ -107,12 +107,15 @@ describe('createInstallPiCommand', () => {
|
|
|
107
107
|
expect(keys).toContain('qwen-cli');
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
it('extensions directory contains
|
|
110
|
+
it('extensions directory contains expected extension package directories', () => {
|
|
111
111
|
const fs = require('node:fs');
|
|
112
112
|
const p = require('node:path');
|
|
113
113
|
const extDir = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions');
|
|
114
|
-
const
|
|
115
|
-
for (const
|
|
114
|
+
const packages = ['auto-session-name', 'auto-update', 'compact-header', 'custom-footer', 'git-checkpoint', 'quality-gates', 'beads', 'session-flow', 'service-skills', 'xtrm-loader', 'plan-mode'];
|
|
115
|
+
for (const pkg of packages) {
|
|
116
|
+
expect(fs.existsSync(p.join(extDir, pkg, 'index.ts'))).toBe(true);
|
|
117
|
+
expect(fs.existsSync(p.join(extDir, pkg, 'package.json'))).toBe(true);
|
|
118
|
+
}
|
|
116
119
|
});
|
|
117
120
|
|
|
118
121
|
it('custom-provider-qwen-cli extension has index.ts and package.json', () => {
|
|
@@ -155,7 +158,7 @@ describe('createInstallPiCommand', () => {
|
|
|
155
158
|
expect(result['DASHSCOPE_API_KEY']).toBe('sk-from-models-789');
|
|
156
159
|
});
|
|
157
160
|
|
|
158
|
-
it('diffPiExtensions reports missing and stale
|
|
161
|
+
it('diffPiExtensions reports missing and stale extension packages', async () => {
|
|
159
162
|
const { diffPiExtensions } = await import('../src/commands/install-pi.js?t=diff' + Date.now());
|
|
160
163
|
const nodeFs = require('node:fs');
|
|
161
164
|
const nodePath = require('node:path');
|
|
@@ -164,17 +167,25 @@ describe('createInstallPiCommand', () => {
|
|
|
164
167
|
const srcDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-ext-src-'));
|
|
165
168
|
const dstDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-ext-dst-'));
|
|
166
169
|
|
|
167
|
-
nodeFs.
|
|
168
|
-
nodeFs.writeFileSync(nodePath.join(srcDir, '
|
|
169
|
-
nodeFs.writeFileSync(nodePath.join(
|
|
170
|
+
nodeFs.mkdirSync(nodePath.join(srcDir, 'a'));
|
|
171
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'a', 'package.json'), JSON.stringify({ name: 'a' }));
|
|
172
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'a', 'index.ts'), 'export const a = 1;');
|
|
173
|
+
|
|
174
|
+
nodeFs.mkdirSync(nodePath.join(srcDir, 'b'));
|
|
175
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'b', 'package.json'), JSON.stringify({ name: 'b' }));
|
|
176
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'b', 'index.ts'), 'export const b = 1;');
|
|
177
|
+
|
|
178
|
+
nodeFs.mkdirSync(nodePath.join(dstDir, 'a'));
|
|
179
|
+
nodeFs.writeFileSync(nodePath.join(dstDir, 'a', 'package.json'), JSON.stringify({ name: 'a' }));
|
|
180
|
+
nodeFs.writeFileSync(nodePath.join(dstDir, 'a', 'index.ts'), 'export const a = 2;');
|
|
170
181
|
|
|
171
182
|
const diff = await diffPiExtensions(srcDir, dstDir);
|
|
172
183
|
|
|
173
|
-
expect(diff.missing).toContain('b
|
|
174
|
-
expect(diff.stale).toContain('a
|
|
184
|
+
expect(diff.missing).toContain('b');
|
|
185
|
+
expect(diff.stale).toContain('a');
|
|
175
186
|
|
|
176
|
-
nodeFs.rmSync(srcDir, { recursive: true });
|
|
177
|
-
nodeFs.rmSync(dstDir, { recursive: true });
|
|
187
|
+
nodeFs.rmSync(srcDir, { recursive: true, force: true });
|
|
188
|
+
nodeFs.rmSync(dstDir, { recursive: true, force: true });
|
|
178
189
|
});
|
|
179
190
|
|
|
180
191
|
it('createInstallPiCommand supports --check flag', () => {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { syncManagedPiExtensions } from '../src/utils/pi-extensions.js';
|
|
6
|
+
|
|
7
|
+
describe('syncManagedPiExtensions', () => {
|
|
8
|
+
it('copies extension packages and reports package count', async () => {
|
|
9
|
+
const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-src-'));
|
|
10
|
+
const dstRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-dst-'));
|
|
11
|
+
|
|
12
|
+
fs.mkdirSync(path.join(srcRoot, 'plan-mode'));
|
|
13
|
+
fs.writeFileSync(path.join(srcRoot, 'plan-mode', 'package.json'), '{}');
|
|
14
|
+
fs.writeFileSync(path.join(srcRoot, 'plan-mode', 'index.ts'), 'export default {};');
|
|
15
|
+
fs.mkdirSync(path.join(srcRoot, 'beads'));
|
|
16
|
+
fs.writeFileSync(path.join(srcRoot, 'beads', 'package.json'), '{}');
|
|
17
|
+
|
|
18
|
+
const count = await syncManagedPiExtensions({
|
|
19
|
+
sourceDir: srcRoot,
|
|
20
|
+
targetDir: dstRoot,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
expect(count).toBe(2);
|
|
24
|
+
expect(fs.existsSync(path.join(dstRoot, 'plan-mode', 'index.ts'))).toBe(true);
|
|
25
|
+
|
|
26
|
+
fs.rmSync(srcRoot, { recursive: true, force: true });
|
|
27
|
+
fs.rmSync(dstRoot, { recursive: true, force: true });
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('supports dry-run without writing files', async () => {
|
|
31
|
+
const srcRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-src-'));
|
|
32
|
+
const dstRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'pi-ext-dst-'));
|
|
33
|
+
fs.mkdirSync(path.join(srcRoot, 'quality-gates'));
|
|
34
|
+
|
|
35
|
+
const logs: string[] = [];
|
|
36
|
+
const count = await syncManagedPiExtensions({
|
|
37
|
+
sourceDir: srcRoot,
|
|
38
|
+
targetDir: dstRoot,
|
|
39
|
+
dryRun: true,
|
|
40
|
+
log: (message) => logs.push(message),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
expect(count).toBe(1);
|
|
44
|
+
expect(fs.existsSync(path.join(dstRoot, 'quality-gates'))).toBe(false);
|
|
45
|
+
expect(logs.some((message) => message.includes('[DRY RUN]'))).toBe(true);
|
|
46
|
+
|
|
47
|
+
fs.rmSync(srcRoot, { recursive: true, force: true });
|
|
48
|
+
fs.rmSync(dstRoot, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
});
|
|
@@ -82,58 +82,48 @@ describe('session launcher CLI surface (2q8j)', () => {
|
|
|
82
82
|
|
|
83
83
|
describe('worktree creation naming convention (2q8j)', () => {
|
|
84
84
|
|
|
85
|
-
it('creates worktree with xt/<name> branch
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
const expectedName = `myproject-xt-claude-${dateStr}`;
|
|
89
|
-
const expectedPath = path.join(siblingBase, expectedName);
|
|
85
|
+
it('creates worktree inside repo under .xtrm/worktrees/ with xt/<name> branch', () => {
|
|
86
|
+
// Worktree lands at <repoDir>/.xtrm/worktrees/myproject-xt-claude-mysession
|
|
87
|
+
const expectedPath = path.join(repoDir, '.xtrm', 'worktrees', 'myproject-xt-claude-mysession');
|
|
90
88
|
|
|
91
|
-
// Pre-clean any stale worktree from previous runs
|
|
92
89
|
if (fs.existsSync(expectedPath)) {
|
|
93
90
|
removeWorktree(expectedPath, repoDir);
|
|
94
91
|
}
|
|
95
92
|
|
|
96
|
-
run(['claude', 'mysession'], {
|
|
97
|
-
cwd: repoDir,
|
|
98
|
-
env: { PATH: '/usr/bin:/bin' },
|
|
99
|
-
});
|
|
93
|
+
run(['claude', 'mysession'], { cwd: repoDir, env: { PATH: '/usr/bin:/bin' } });
|
|
100
94
|
|
|
101
|
-
|
|
102
|
-
const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
103
|
-
cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
|
|
104
|
-
});
|
|
105
|
-
expect(branchResult.stdout.trim()).toBe('xt/mysession');
|
|
95
|
+
expect(fs.existsSync(expectedPath)).toBe(true);
|
|
106
96
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
97
|
+
const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
98
|
+
cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
|
|
99
|
+
});
|
|
100
|
+
expect(branchResult.stdout.trim()).toBe('xt/mysession');
|
|
111
101
|
|
|
112
|
-
|
|
113
|
-
|
|
102
|
+
// Worktree is nested inside repoDir, not a sibling
|
|
103
|
+
expect(expectedPath.startsWith(repoDir + path.sep)).toBe(true);
|
|
104
|
+
|
|
105
|
+
removeWorktree(expectedPath, repoDir);
|
|
114
106
|
});
|
|
115
107
|
|
|
116
108
|
it('creates worktree with random xt/<slug> branch when no name provided', () => {
|
|
117
|
-
|
|
118
|
-
const
|
|
119
|
-
const expectedName = `myproject-xt-pi-${dateStr}`;
|
|
120
|
-
const expectedPath = path.join(siblingBase, expectedName);
|
|
109
|
+
// Worktree lands at <repoDir>/.xtrm/worktrees/myproject-xt-pi-<slug>
|
|
110
|
+
const worktreesDir = path.join(repoDir, '.xtrm', 'worktrees');
|
|
121
111
|
|
|
122
|
-
|
|
123
|
-
removeWorktree(expectedPath, repoDir);
|
|
124
|
-
}
|
|
112
|
+
run(['pi'], { cwd: repoDir, env: { PATH: '/usr/bin:/bin' } });
|
|
125
113
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
114
|
+
// Find the created worktree (slug is random, so glob the dir)
|
|
115
|
+
const entries = fs.existsSync(worktreesDir)
|
|
116
|
+
? fs.readdirSync(worktreesDir).filter(e => e.startsWith('myproject-xt-pi-'))
|
|
117
|
+
: [];
|
|
118
|
+
|
|
119
|
+
expect(entries.length).toBeGreaterThan(0);
|
|
120
|
+
|
|
121
|
+
const wtPath = path.join(worktreesDir, entries[0]!);
|
|
122
|
+
const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
123
|
+
cwd: wtPath, encoding: 'utf8', stdio: 'pipe',
|
|
129
124
|
});
|
|
125
|
+
expect(branchResult.stdout.trim()).toMatch(/^xt\/[a-z0-9]{4}$/);
|
|
130
126
|
|
|
131
|
-
|
|
132
|
-
const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
133
|
-
cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
|
|
134
|
-
});
|
|
135
|
-
expect(branchResult.stdout.trim()).toMatch(/^xt\/[a-z0-9]{4}$/);
|
|
136
|
-
removeWorktree(expectedPath, repoDir);
|
|
137
|
-
}
|
|
127
|
+
removeWorktree(wtPath, repoDir);
|
|
138
128
|
});
|
|
139
129
|
});
|