xtrm-cli 2.1.20 → 2.1.29
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/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/extensions/beads.ts +99 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +88 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/package.json +1 -1
- package/src/commands/install-pi.ts +12 -0
- package/src/commands/install-project.ts +1 -1
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/install-pi.test.ts +39 -1
- package/test/install-project.test.ts +29 -26
- package/vitest.config.ts +1 -0
|
@@ -14,6 +14,18 @@ interface SchemaField { key: string; label: string; hint: string; secret: boolea
|
|
|
14
14
|
interface OAuthProvider { key: string; instruction: string; }
|
|
15
15
|
interface InstallSchema { fields: SchemaField[]; oauth_providers: OAuthProvider[]; packages: string[]; }
|
|
16
16
|
|
|
17
|
+
export const EXTRA_PI_CONFIGS = ['pi-worktrees-settings.json'];
|
|
18
|
+
|
|
19
|
+
export async function copyExtraConfigs(srcDir: string, destDir: string): Promise<void> {
|
|
20
|
+
for (const name of EXTRA_PI_CONFIGS) {
|
|
21
|
+
const src = path.join(srcDir, name);
|
|
22
|
+
const dest = path.join(destDir, name);
|
|
23
|
+
if (await fs.pathExists(src) && !await fs.pathExists(dest)) {
|
|
24
|
+
await fs.copy(src, dest);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
17
29
|
export function fillTemplate(template: string, values: Record<string, string>): string {
|
|
18
30
|
return template.replace(/\{\{(\w+)\}\}/g, (_, key) => values[key] ?? '');
|
|
19
31
|
}
|
|
@@ -127,7 +127,7 @@ export async function getAvailableProjectSkills(): Promise<string[]> {
|
|
|
127
127
|
for (const entry of entries) {
|
|
128
128
|
const entryPath = path.join(PROJECT_SKILLS_DIR, entry);
|
|
129
129
|
const stat = await fs.stat(entryPath);
|
|
130
|
-
if (stat.isDirectory()) {
|
|
130
|
+
if (stat.isDirectory() && await fs.pathExists(path.join(entryPath, '.claude'))) {
|
|
131
131
|
skills.push(entry);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import beadsExtension from "../../extensions/beads";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
8
|
+
return {
|
|
9
|
+
SubprocessRunner: {
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
EventAdapter: {
|
|
13
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write"),
|
|
14
|
+
},
|
|
15
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
16
|
+
this.debug = vi.fn();
|
|
17
|
+
this.info = vi.fn();
|
|
18
|
+
this.warn = vi.fn();
|
|
19
|
+
this.error = vi.fn();
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.mock("node:fs", () => ({
|
|
25
|
+
existsSync: vi.fn(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
describe("Beads Extension", () => {
|
|
29
|
+
let harness: ExtensionHarness;
|
|
30
|
+
|
|
31
|
+
beforeEach(() => {
|
|
32
|
+
vi.resetAllMocks();
|
|
33
|
+
harness = new ExtensionHarness();
|
|
34
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should block edits when claim check fails", async () => {
|
|
38
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
39
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 1, stdout: "", stderr: "" };
|
|
40
|
+
if (args[0] === "list") {
|
|
41
|
+
return { code: 0, stdout: "Total: 5 issues (3 open, 2 in progress)", stderr: "" };
|
|
42
|
+
}
|
|
43
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beadsExtension(harness.pi);
|
|
47
|
+
|
|
48
|
+
const result = await harness.emit("tool_call", {
|
|
49
|
+
toolName: "write",
|
|
50
|
+
input: { path: "src/main.ts" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).toBeDefined();
|
|
54
|
+
if (result) {
|
|
55
|
+
expect(result.block).toBe(true);
|
|
56
|
+
expect(result.reason).toContain("No active issue claim");
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("should allow edits when an issue is claimed", async () => {
|
|
61
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
62
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "issue-123", stderr: "" };
|
|
63
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beadsExtension(harness.pi);
|
|
67
|
+
|
|
68
|
+
const result = await harness.emit("tool_call", {
|
|
69
|
+
toolName: "write",
|
|
70
|
+
input: { path: "src/main.ts" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should block git commit when an issue is claimed", async () => {
|
|
77
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
78
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "issue-123", stderr: "" };
|
|
79
|
+
if (args[0] === "list") {
|
|
80
|
+
return { code: 0, stdout: "Total: 1 issues (0 open, 1 in progress)\n◐ issue-123 Title", stderr: "" };
|
|
81
|
+
}
|
|
82
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beadsExtension(harness.pi);
|
|
86
|
+
|
|
87
|
+
const result = await harness.emit("tool_call", {
|
|
88
|
+
toolName: "bash",
|
|
89
|
+
input: { command: "git commit -m 'feat: something'" },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(result).toBeDefined();
|
|
93
|
+
if (result) {
|
|
94
|
+
expect(result.block).toBe(true);
|
|
95
|
+
expect(result.reason).toContain("Resolve open claim [issue-123]");
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should inject memory reminder on bd close", async () => {
|
|
100
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "", stderr: "" });
|
|
101
|
+
|
|
102
|
+
beadsExtension(harness.pi);
|
|
103
|
+
|
|
104
|
+
const result = await harness.emit("tool_result", {
|
|
105
|
+
toolName: "bash",
|
|
106
|
+
input: { command: "bd close issue-123" },
|
|
107
|
+
content: [{ type: "text", text: "Issue closed successfully." }],
|
|
108
|
+
isError: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
expect(result.content).toHaveLength(2);
|
|
112
|
+
expect(result.content[1].text).toContain("Beads Insight");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("should auto-claim session on bd update --claim", async () => {
|
|
116
|
+
const kvSetCalls: string[][] = [];
|
|
117
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
118
|
+
if (args[0] === "kv" && args[1] === "set") {
|
|
119
|
+
kvSetCalls.push(args);
|
|
120
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
121
|
+
}
|
|
122
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
beadsExtension(harness.pi);
|
|
126
|
+
|
|
127
|
+
const result = await harness.emit("tool_result", {
|
|
128
|
+
toolName: "bash",
|
|
129
|
+
input: { command: "bd update issue-456 --claim" },
|
|
130
|
+
content: [{ type: "text", text: "Updated issue: issue-456" }],
|
|
131
|
+
isError: false,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(kvSetCalls.length).toBe(1);
|
|
135
|
+
expect(kvSetCalls[0][2]).toBe(`claimed:${process.pid}`);
|
|
136
|
+
expect(kvSetCalls[0][3]).toBe("issue-456");
|
|
137
|
+
expect(result.content[1].text).toContain("claimed issue");
|
|
138
|
+
expect(result.content[1].text).toContain("issue-456");
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
it("should auto-claim even when bd update --claim returns exit 1 (already in_progress)", async () => {
|
|
143
|
+
const kvSetCalls: string[][] = [];
|
|
144
|
+
(SubprocessRunner.run as any).mockImplementation(async (cmd: string, args: string[]) => {
|
|
145
|
+
if (args[0] === "kv" && args[1] === "set") {
|
|
146
|
+
kvSetCalls.push(args);
|
|
147
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
148
|
+
}
|
|
149
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
beadsExtension(harness.pi);
|
|
153
|
+
|
|
154
|
+
const result = await harness.emit("tool_result", {
|
|
155
|
+
toolName: "bash",
|
|
156
|
+
input: { command: "bd update issue-789 --claim" },
|
|
157
|
+
content: [{ type: "text", text: "already in_progress" }],
|
|
158
|
+
isError: true,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(kvSetCalls.length).toBe(1);
|
|
162
|
+
expect(kvSetCalls[0][2]).toBe(`claimed:${process.pid}`);
|
|
163
|
+
expect(kvSetCalls[0][3]).toBe("issue-789");
|
|
164
|
+
expect(result.content[1].text).toContain("issue-789");
|
|
165
|
+
});
|
|
166
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
export interface MockUI {
|
|
4
|
+
notify: any;
|
|
5
|
+
confirm: any;
|
|
6
|
+
select: any;
|
|
7
|
+
setStatus: any;
|
|
8
|
+
theme: {
|
|
9
|
+
fg: any;
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MockSessionManager {
|
|
14
|
+
sessionId: string;
|
|
15
|
+
getEntries: any;
|
|
16
|
+
getLeafEntry: any;
|
|
17
|
+
getBranch: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface MockContext {
|
|
21
|
+
cwd: string;
|
|
22
|
+
hasUI: boolean;
|
|
23
|
+
ui: MockUI;
|
|
24
|
+
sessionManager: MockSessionManager;
|
|
25
|
+
getSystemPrompt: any;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ExtensionHarness {
|
|
29
|
+
public handlers: Record<string, Function[]> = {};
|
|
30
|
+
public commands: Record<string, any> = {};
|
|
31
|
+
public tools: Record<string, any> = {};
|
|
32
|
+
public ctx: MockContext;
|
|
33
|
+
public pi: any;
|
|
34
|
+
|
|
35
|
+
constructor(cwd: string = "/mock/project") {
|
|
36
|
+
this.ctx = {
|
|
37
|
+
cwd,
|
|
38
|
+
hasUI: true,
|
|
39
|
+
ui: {
|
|
40
|
+
notify: vi.fn(),
|
|
41
|
+
confirm: vi.fn().mockResolvedValue(true),
|
|
42
|
+
select: vi.fn().mockResolvedValue(""),
|
|
43
|
+
setStatus: vi.fn(),
|
|
44
|
+
theme: {
|
|
45
|
+
fg: vi.fn((_color: string, text: string) => text),
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
sessionManager: {
|
|
49
|
+
sessionId: "mock-session-123",
|
|
50
|
+
getEntries: vi.fn().mockReturnValue([]),
|
|
51
|
+
getLeafEntry: vi.fn().mockReturnValue({ id: "last-entry" }),
|
|
52
|
+
getBranch: vi.fn().mockReturnValue([]),
|
|
53
|
+
},
|
|
54
|
+
getSystemPrompt: vi.fn().mockReturnValue("Default system prompt"),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
this.pi = {
|
|
58
|
+
on: (event: string, handler: Function) => {
|
|
59
|
+
if (!this.handlers[event]) this.handlers[event] = [];
|
|
60
|
+
this.handlers[event].push(handler);
|
|
61
|
+
},
|
|
62
|
+
exec: vi.fn().mockResolvedValue({ code: 0, stdout: "", stderr: "" }),
|
|
63
|
+
registerCommand: (cmd: any) => {
|
|
64
|
+
this.commands[cmd.name] = cmd;
|
|
65
|
+
},
|
|
66
|
+
registerTool: (tool: any) => {
|
|
67
|
+
this.tools[tool.name] = tool;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async emit(event: string, data: any) {
|
|
73
|
+
if (this.handlers[event]) {
|
|
74
|
+
let lastResult: any = undefined;
|
|
75
|
+
for (const handler of this.handlers[event]) {
|
|
76
|
+
console.log("EXECUTING HANDLER"); const res = await handler(data, this.ctx); console.log("HANDLER RESULT", res);
|
|
77
|
+
if (res !== undefined) {
|
|
78
|
+
lastResult = res; console.log("EMIT FOUND RESULT", res);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return lastResult;
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import mainGuardExtension from "../../extensions/main-guard";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
|
|
6
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
7
|
+
return {
|
|
8
|
+
SubprocessRunner: {
|
|
9
|
+
run: vi.fn(),
|
|
10
|
+
},
|
|
11
|
+
EventAdapter: {
|
|
12
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write" || event.toolName === "edit"),
|
|
13
|
+
extractPathFromToolInput: vi.fn((event) => event.input.path),
|
|
14
|
+
},
|
|
15
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
16
|
+
this.debug = vi.fn();
|
|
17
|
+
this.info = vi.fn();
|
|
18
|
+
this.warn = vi.fn();
|
|
19
|
+
this.error = vi.fn();
|
|
20
|
+
}),
|
|
21
|
+
};
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("Main Guard Extension", () => {
|
|
25
|
+
let harness: ExtensionHarness;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.resetAllMocks();
|
|
29
|
+
harness = new ExtensionHarness();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("should block edits on main branch", async () => {
|
|
33
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "main" });
|
|
34
|
+
|
|
35
|
+
mainGuardExtension(harness.pi);
|
|
36
|
+
|
|
37
|
+
const result = await harness.emit("tool_call", {
|
|
38
|
+
toolName: "write",
|
|
39
|
+
input: { path: "src/main.ts" },
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
expect(result).toEqual({
|
|
43
|
+
block: true,
|
|
44
|
+
reason: expect.stringContaining("On protected branch 'main'"),
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should allow edits on feature branches", async () => {
|
|
49
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "feature/abc" });
|
|
50
|
+
|
|
51
|
+
mainGuardExtension(harness.pi);
|
|
52
|
+
|
|
53
|
+
const result = await harness.emit("tool_call", {
|
|
54
|
+
toolName: "write",
|
|
55
|
+
input: { path: "src/main.ts" },
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(result).toBeUndefined();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should block rm -rf with confirmation", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockResolvedValue({ code: 0, stdout: "feature/abc" });
|
|
63
|
+
harness.ctx.ui.confirm = vi.fn().mockResolvedValue(false);
|
|
64
|
+
|
|
65
|
+
mainGuardExtension(harness.pi);
|
|
66
|
+
|
|
67
|
+
const result = await harness.emit("tool_call", {
|
|
68
|
+
toolName: "bash",
|
|
69
|
+
input: { command: "rm -rf /important/stuff" },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
expect(result).toEqual({
|
|
73
|
+
block: true,
|
|
74
|
+
reason: "Blocked by user confirmation",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import qualityGatesExtension from "../../extensions/quality-gates";
|
|
4
|
+
import { SubprocessRunner } from "../../extensions/core/lib";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
|
|
7
|
+
vi.mock("../../extensions/core/lib", async () => {
|
|
8
|
+
return {
|
|
9
|
+
SubprocessRunner: {
|
|
10
|
+
run: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
EventAdapter: {
|
|
13
|
+
isMutatingFileTool: vi.fn((event) => event.toolName === "write" || event.toolName === "edit"),
|
|
14
|
+
extractPathFromToolInput: vi.fn((event) => event.input.path),
|
|
15
|
+
},
|
|
16
|
+
Logger: vi.fn().mockImplementation(function() {
|
|
17
|
+
this.debug = vi.fn();
|
|
18
|
+
this.info = vi.fn();
|
|
19
|
+
this.warn = vi.fn();
|
|
20
|
+
this.error = vi.fn();
|
|
21
|
+
}),
|
|
22
|
+
};
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
vi.mock("node:fs", () => ({
|
|
26
|
+
existsSync: vi.fn(),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
describe("Quality Gates Extension", () => {
|
|
30
|
+
let harness: ExtensionHarness;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
vi.resetAllMocks();
|
|
34
|
+
harness = new ExtensionHarness();
|
|
35
|
+
(fs.existsSync as any).mockReturnValue(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("should run quality check for .ts files", async () => {
|
|
39
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
40
|
+
code: 0,
|
|
41
|
+
stdout: "Passed",
|
|
42
|
+
stderr: "ESLint auto-fixed issues",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
qualityGatesExtension(harness.pi);
|
|
46
|
+
|
|
47
|
+
const result = await harness.emit("tool_result", {
|
|
48
|
+
toolName: "write",
|
|
49
|
+
input: { path: "src/main.ts" },
|
|
50
|
+
content: [{ type: "text", text: "Original content" }],
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(SubprocessRunner.run).toHaveBeenCalledWith(
|
|
54
|
+
"node",
|
|
55
|
+
expect.arrayContaining([expect.stringContaining("quality-check.cjs")]),
|
|
56
|
+
expect.any(Object)
|
|
57
|
+
);
|
|
58
|
+
expect(result.content[1].text).toContain("ESLint auto-fixed issues");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should fail tool result when quality check returns status 2", async () => {
|
|
62
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
63
|
+
code: 2,
|
|
64
|
+
stdout: "",
|
|
65
|
+
stderr: "Compilation failed: error TS1234",
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
qualityGatesExtension(harness.pi);
|
|
69
|
+
|
|
70
|
+
const result = await harness.emit("tool_result", {
|
|
71
|
+
toolName: "write",
|
|
72
|
+
input: { path: "src/main.ts" },
|
|
73
|
+
content: [{ type: "text", text: "Original content" }],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
expect(result.isError).toBe(true);
|
|
77
|
+
expect(result.content[1].text).toContain("Compilation failed");
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import xtrmLoaderExtension from "../../extensions/xtrm-loader";
|
|
4
|
+
import * as fs from "node:fs";
|
|
5
|
+
|
|
6
|
+
vi.mock("node:fs", () => ({
|
|
7
|
+
existsSync: vi.fn(),
|
|
8
|
+
readFileSync: vi.fn(),
|
|
9
|
+
readdirSync: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
describe("XTRM Loader Extension", () => {
|
|
13
|
+
let harness: ExtensionHarness;
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
vi.resetAllMocks();
|
|
17
|
+
harness = new ExtensionHarness();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("should load project roadmap and rules", async () => {
|
|
21
|
+
(fs.existsSync as any).mockImplementation((p: string) => {
|
|
22
|
+
if (p.endsWith("ROADMAP.md")) return true;
|
|
23
|
+
if (p.endsWith(".claude/rules")) return true;
|
|
24
|
+
if (p.endsWith(".claude/skills")) return false;
|
|
25
|
+
return false;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
(fs.readFileSync as any).mockImplementation((p: string) => {
|
|
29
|
+
if (p.endsWith("ROADMAP.md")) return "My Roadmap Content";
|
|
30
|
+
if (p.endsWith("rule1.md")) return "Rule 1 Content";
|
|
31
|
+
return "";
|
|
32
|
+
});
|
|
33
|
+
|
|
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
|
+
xtrmLoaderExtension(harness.pi);
|
|
40
|
+
|
|
41
|
+
// Trigger session_start to load data
|
|
42
|
+
await harness.emit("session_start", {});
|
|
43
|
+
|
|
44
|
+
// Trigger before_agent_start to see injection
|
|
45
|
+
const result = await harness.emit("before_agent_start", {
|
|
46
|
+
systemPrompt: "Base prompt"
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(result.systemPrompt).toContain("My Roadmap Content");
|
|
50
|
+
expect(result.systemPrompt).toContain("Rule 1 Content");
|
|
51
|
+
expect(harness.ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("context and skills indexed"), "info");
|
|
52
|
+
});
|
|
53
|
+
});
|
package/test/install-pi.test.ts
CHANGED
|
@@ -51,6 +51,44 @@ describe('createInstallPiCommand', () => {
|
|
|
51
51
|
expect(settings.packages).toContain('npm:pi-serena-tools');
|
|
52
52
|
});
|
|
53
53
|
|
|
54
|
+
it('settings.json.template includes @zenobius/pi-worktrees package', () => {
|
|
55
|
+
const fs = require('node:fs');
|
|
56
|
+
const p = require('node:path');
|
|
57
|
+
const settings = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'settings.json.template'), 'utf8'));
|
|
58
|
+
expect(settings.packages).toContain('npm:@zenobius/pi-worktrees');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('copyExtraConfigs copies missing files and skips existing ones', async () => {
|
|
62
|
+
const { copyExtraConfigs, EXTRA_PI_CONFIGS } = await import('../src/commands/install-pi.js?t=copy' + Date.now());
|
|
63
|
+
const os = require('node:os');
|
|
64
|
+
const nodePath = require('node:path');
|
|
65
|
+
const nodeFs = require('node:fs');
|
|
66
|
+
const srcDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-src-'));
|
|
67
|
+
const destDir = nodeFs.mkdtempSync(nodePath.join(os.tmpdir(), 'pi-dest-'));
|
|
68
|
+
// Create src file
|
|
69
|
+
nodeFs.writeFileSync(nodePath.join(srcDir, 'pi-worktrees-settings.json'), '{"worktree":{}}');
|
|
70
|
+
await copyExtraConfigs(srcDir, destDir);
|
|
71
|
+
// Should have been copied
|
|
72
|
+
expect(nodeFs.existsSync(nodePath.join(destDir, 'pi-worktrees-settings.json'))).toBe(true);
|
|
73
|
+
// Second call should skip (not throw)
|
|
74
|
+
await copyExtraConfigs(srcDir, destDir);
|
|
75
|
+
nodeFs.rmSync(srcDir, { recursive: true });
|
|
76
|
+
nodeFs.rmSync(destDir, { recursive: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('EXTRA_PI_CONFIGS includes pi-worktrees-settings.json', async () => {
|
|
80
|
+
const { EXTRA_PI_CONFIGS } = await import('../src/commands/install-pi.js?t=extra' + Date.now());
|
|
81
|
+
expect(EXTRA_PI_CONFIGS).toContain('pi-worktrees-settings.json');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('pi-worktrees-settings.json exists in config/pi with worktree.parentDir defined', () => {
|
|
85
|
+
const fs = require('node:fs');
|
|
86
|
+
const p = require('node:path');
|
|
87
|
+
const cfg = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'pi-worktrees-settings.json'), 'utf8'));
|
|
88
|
+
expect(cfg.worktree).toBeDefined();
|
|
89
|
+
expect(cfg.worktree.parentDir).toBeDefined();
|
|
90
|
+
});
|
|
91
|
+
|
|
54
92
|
it('install-schema.json defines DASHSCOPE_API_KEY and ZAI_API_KEY fields', () => {
|
|
55
93
|
const fs = require('node:fs');
|
|
56
94
|
const p = require('node:path');
|
|
@@ -73,7 +111,7 @@ describe('createInstallPiCommand', () => {
|
|
|
73
111
|
const fs = require('node:fs');
|
|
74
112
|
const p = require('node:path');
|
|
75
113
|
const extDir = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions');
|
|
76
|
-
const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','
|
|
114
|
+
const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','todo.ts'];
|
|
77
115
|
for (const f of files) expect(fs.existsSync(p.join(extDir, f))).toBe(true);
|
|
78
116
|
});
|
|
79
117
|
|
|
@@ -120,13 +120,13 @@ describe('deepMergeHooks', () => {
|
|
|
120
120
|
describe('extractReadmeDescription', () => {
|
|
121
121
|
it('extracts the first prose line after the title', async () => {
|
|
122
122
|
const readme = await fsExtra.readFile(
|
|
123
|
-
path.join(__dirname, '../../project-skills/
|
|
123
|
+
path.join(__dirname, '../../project-skills/quality-gates/README.md'),
|
|
124
124
|
'utf8',
|
|
125
125
|
);
|
|
126
126
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
);
|
|
127
|
+
const description = extractReadmeDescription(readme);
|
|
128
|
+
expect(description).toBeTruthy();
|
|
129
|
+
expect(description).not.toBe('No description available');
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
it('skips badge blocks and finds the first actual description line', async () => {
|
|
@@ -154,12 +154,11 @@ describe('installProjectSkill', () => {
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
it('copies hook assets required by the installed project skill', async () => {
|
|
157
|
-
await installProjectSkill('
|
|
157
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
158
158
|
|
|
159
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', '
|
|
160
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', '
|
|
161
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', '
|
|
162
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'ts-quality-gate-readme.md'))).toBe(true);
|
|
159
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs'))).toBe(true);
|
|
160
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
|
|
161
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
|
|
163
162
|
});
|
|
164
163
|
|
|
165
164
|
it('merges settings without dropping existing project hooks', async () => {
|
|
@@ -188,22 +187,22 @@ describe('installProjectSkill', () => {
|
|
|
188
187
|
type: 'module',
|
|
189
188
|
}, { spaces: 2 });
|
|
190
189
|
|
|
191
|
-
await installProjectSkill('
|
|
190
|
+
await installProjectSkill('tdd-guard', tmpDir);
|
|
192
191
|
|
|
193
|
-
const
|
|
192
|
+
const guardRun = spawnSync(
|
|
194
193
|
'node',
|
|
195
|
-
[path.join(tmpDir, '.claude', 'hooks', '
|
|
194
|
+
[path.join(tmpDir, '.claude', 'hooks', 'tdd-guard-pretool-bridge.cjs')],
|
|
196
195
|
{
|
|
197
196
|
cwd: tmpDir,
|
|
198
197
|
input: '{"tool_name":"Edit","tool_input":{"file_path":"/tmp/does-not-exist.ts"}}',
|
|
199
198
|
encoding: 'utf8',
|
|
200
199
|
},
|
|
201
200
|
);
|
|
202
|
-
expect(
|
|
201
|
+
expect(guardRun.status).toBe(0);
|
|
203
202
|
|
|
204
203
|
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
205
|
-
const
|
|
206
|
-
expect(
|
|
204
|
+
const preToolUseCommand = settings.hooks.PreToolUse[0].hooks[0].command;
|
|
205
|
+
expect(preToolUseCommand).toContain('tdd-guard-pretool-bridge.cjs');
|
|
207
206
|
});
|
|
208
207
|
|
|
209
208
|
it('installs service-skills git hooks when service-skills-set is installed', async () => {
|
|
@@ -229,36 +228,40 @@ describe('installAllProjectSkills', () => {
|
|
|
229
228
|
await rm(tmpDir, { recursive: true, force: true });
|
|
230
229
|
});
|
|
231
230
|
|
|
231
|
+
it('getAvailableProjectSkills only returns skills with a .claude directory', async () => {
|
|
232
|
+
const availableSkills = await getAvailableProjectSkills();
|
|
233
|
+
// All returned skills must have a .claude dir (eval-only dirs are excluded)
|
|
234
|
+
for (const skill of availableSkills) {
|
|
235
|
+
expect(availableSkills).toContain(skill);
|
|
236
|
+
}
|
|
237
|
+
expect(availableSkills).toContain('tdd-guard');
|
|
238
|
+
expect(availableSkills).toContain('service-skills-set');
|
|
239
|
+
expect(availableSkills).toContain('quality-gates');
|
|
240
|
+
});
|
|
241
|
+
|
|
232
242
|
it('installs every available project skill with merged hooks and copied assets', async () => {
|
|
233
243
|
const availableSkills = await getAvailableProjectSkills();
|
|
234
244
|
expect(availableSkills).toEqual([
|
|
235
|
-
'
|
|
245
|
+
'quality-gates',
|
|
236
246
|
'service-skills-set',
|
|
237
247
|
'tdd-guard',
|
|
238
|
-
'ts-quality-gate',
|
|
239
248
|
]);
|
|
240
249
|
|
|
241
250
|
await installAllProjectSkills(tmpDir);
|
|
242
251
|
|
|
243
252
|
const settings = await fsExtra.readJson(path.join(tmpDir, '.claude', 'settings.json'));
|
|
244
|
-
expect(settings.hooks.
|
|
245
|
-
expect(settings.hooks.PreToolUse).toHaveLength(2);
|
|
246
|
-
expect(settings.hooks.PostToolUse).toHaveLength(3);
|
|
247
|
-
expect(settings.hooks.UserPromptSubmit).toHaveLength(1);
|
|
253
|
+
expect(settings.hooks.PreToolUse).toHaveLength(1); // tdd-guard
|
|
248
254
|
|
|
249
255
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.cjs'))).toBe(true);
|
|
250
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'hooks', 'quality-check.py'))).toBe(true);
|
|
251
256
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'doc_reminder.py'))).toBe(true);
|
|
252
257
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'service-registry.json'))).toBe(true);
|
|
253
258
|
|
|
254
259
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-tdd-guard', 'SKILL.md'))).toBe(true);
|
|
255
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-ts-quality-gate', 'SKILL.md'))).toBe(true);
|
|
256
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-py-quality-gate', 'SKILL.md'))).toBe(true);
|
|
257
260
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-service-skills', 'SKILL.md'))).toBe(true);
|
|
261
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'skills', 'using-quality-gates', 'SKILL.md'))).toBe(true);
|
|
258
262
|
|
|
259
263
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'tdd-guard-readme.md'))).toBe(true);
|
|
260
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'ts-quality-gate-readme.md'))).toBe(true);
|
|
261
|
-
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'py-quality-gate-readme.md'))).toBe(true);
|
|
262
264
|
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'service-skills-set-readme.md'))).toBe(true);
|
|
265
|
+
expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'docs', 'quality-gates-readme.md'))).toBe(true);
|
|
263
266
|
});
|
|
264
267
|
});
|