xtrm-cli 0.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/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -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/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- 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/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -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,107 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import minimalModeExtension from "../../extensions/minimal-mode";
|
|
3
|
+
|
|
4
|
+
vi.mock("@mariozechner/pi-coding-agent", async () => {
|
|
5
|
+
const mk = () => ({
|
|
6
|
+
description: "mock",
|
|
7
|
+
parameters: { type: "object", properties: {} },
|
|
8
|
+
execute: vi.fn(async () => ({ content: [{ type: "text", text: "ok" }] })),
|
|
9
|
+
});
|
|
10
|
+
return {
|
|
11
|
+
createReadTool: () => mk(),
|
|
12
|
+
createBashTool: () => mk(),
|
|
13
|
+
createEditTool: () => mk(),
|
|
14
|
+
createWriteTool: () => mk(),
|
|
15
|
+
createFindTool: () => mk(),
|
|
16
|
+
createGrepTool: () => mk(),
|
|
17
|
+
createLsTool: () => mk(),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("@mariozechner/pi-tui", async () => {
|
|
22
|
+
class Text {
|
|
23
|
+
constructor(public value: string) {}
|
|
24
|
+
}
|
|
25
|
+
return { Text };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("minimal-mode extension", () => {
|
|
29
|
+
let handlers: Record<string, Function[]>;
|
|
30
|
+
let commands: Record<string, any>;
|
|
31
|
+
let tools: Record<string, any>;
|
|
32
|
+
let ctx: any;
|
|
33
|
+
let pi: any;
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
vi.useFakeTimers();
|
|
37
|
+
handlers = {};
|
|
38
|
+
commands = {};
|
|
39
|
+
tools = {};
|
|
40
|
+
ctx = {
|
|
41
|
+
cwd: "/mock/project",
|
|
42
|
+
hasUI: true,
|
|
43
|
+
ui: {
|
|
44
|
+
notify: vi.fn(),
|
|
45
|
+
setStatus: vi.fn(),
|
|
46
|
+
setHeader: vi.fn(),
|
|
47
|
+
theme: {
|
|
48
|
+
fg: vi.fn((_color: string, text: string) => text),
|
|
49
|
+
bold: vi.fn((text: string) => text),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
pi = {
|
|
54
|
+
on: (event: string, handler: Function) => {
|
|
55
|
+
if (!handlers[event]) handlers[event] = [];
|
|
56
|
+
handlers[event].push(handler);
|
|
57
|
+
},
|
|
58
|
+
registerTool: (tool: any) => {
|
|
59
|
+
tools[tool.name] = tool;
|
|
60
|
+
},
|
|
61
|
+
registerCommand: (nameOrDef: any, maybeDef?: any) => {
|
|
62
|
+
if (typeof nameOrDef === "string") commands[nameOrDef] = maybeDef;
|
|
63
|
+
else commands[nameOrDef.name] = nameOrDef;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.useRealTimers();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const emit = async (event: string, data: any = {}) => {
|
|
73
|
+
for (const h of handlers[event] || []) await h(data, ctx);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
it("registers minimal/thinking commands and tool overrides", () => {
|
|
77
|
+
minimalModeExtension(pi);
|
|
78
|
+
|
|
79
|
+
expect(commands["minimal-on"]).toBeDefined();
|
|
80
|
+
expect(commands["minimal-off"]).toBeDefined();
|
|
81
|
+
expect(commands["minimal-toggle"]).toBeDefined();
|
|
82
|
+
expect(commands["thinking-status-toggle"]).toBeDefined();
|
|
83
|
+
|
|
84
|
+
expect(tools["bash"]).toBeDefined();
|
|
85
|
+
expect(tools["read"]).toBeDefined();
|
|
86
|
+
expect(tools["write"]).toBeDefined();
|
|
87
|
+
expect(tools["edit"]).toBeDefined();
|
|
88
|
+
expect(tools["find"]).toBeDefined();
|
|
89
|
+
expect(tools["grep"]).toBeDefined();
|
|
90
|
+
expect(tools["ls"]).toBeDefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("starts and clears thinking status/header across turn lifecycle", async () => {
|
|
94
|
+
minimalModeExtension(pi);
|
|
95
|
+
|
|
96
|
+
await emit("turn_start", {});
|
|
97
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith("thinking", "thinking ");
|
|
98
|
+
expect(ctx.ui.setHeader).toHaveBeenCalled();
|
|
99
|
+
|
|
100
|
+
vi.advanceTimersByTime(240);
|
|
101
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith("thinking", "thinking. ");
|
|
102
|
+
|
|
103
|
+
await emit("turn_end", {});
|
|
104
|
+
expect(ctx.ui.setStatus).toHaveBeenCalledWith("thinking", undefined);
|
|
105
|
+
expect(ctx.ui.setHeader).toHaveBeenCalledWith(undefined);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -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,84 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import serviceSkillsExtension from "../../extensions/service-skills";
|
|
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
|
+
Logger: vi.fn().mockImplementation(function () {
|
|
13
|
+
this.debug = vi.fn();
|
|
14
|
+
this.info = vi.fn();
|
|
15
|
+
this.warn = vi.fn();
|
|
16
|
+
this.error = vi.fn();
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("node:fs", () => ({
|
|
22
|
+
existsSync: vi.fn(),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
describe("Service Skills Extension", () => {
|
|
26
|
+
let harness: ExtensionHarness;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.resetAllMocks();
|
|
30
|
+
harness = new ExtensionHarness("/mock/project");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("gracefully no-ops when service-registry.json is absent", async () => {
|
|
34
|
+
(fs.existsSync as any).mockReturnValue(false);
|
|
35
|
+
|
|
36
|
+
serviceSkillsExtension(harness.pi);
|
|
37
|
+
|
|
38
|
+
const beforeStart = await harness.emit("before_agent_start", {
|
|
39
|
+
systemPrompt: "Base prompt",
|
|
40
|
+
});
|
|
41
|
+
const toolResult = await harness.emit("tool_result", {
|
|
42
|
+
toolName: "write",
|
|
43
|
+
input: { path: "src/file.ts" },
|
|
44
|
+
content: [{ type: "text", text: "ok" }],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
expect(beforeStart).toBeUndefined();
|
|
48
|
+
expect(toolResult).toBeUndefined();
|
|
49
|
+
expect(SubprocessRunner.run).not.toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("injects catalog when registry + cataloger script are present", async () => {
|
|
53
|
+
(fs.existsSync as any).mockImplementation((p: string) => {
|
|
54
|
+
if (p === "/mock/project/service-registry.json") return true;
|
|
55
|
+
if (p.includes(".claude/skills/using-service-skills/scripts/cataloger.py")) return true;
|
|
56
|
+
return false;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
(SubprocessRunner.run as any).mockResolvedValue({
|
|
60
|
+
code: 0,
|
|
61
|
+
stdout: "<project_service_catalog>...</project_service_catalog>",
|
|
62
|
+
stderr: "",
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
serviceSkillsExtension(harness.pi);
|
|
66
|
+
|
|
67
|
+
const result = await harness.emit("before_agent_start", {
|
|
68
|
+
systemPrompt: "Base prompt",
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(SubprocessRunner.run).toHaveBeenCalledWith(
|
|
72
|
+
"python3",
|
|
73
|
+
expect.arrayContaining([expect.stringContaining("cataloger.py")]),
|
|
74
|
+
expect.objectContaining({
|
|
75
|
+
cwd: "/mock/project",
|
|
76
|
+
env: expect.objectContaining({
|
|
77
|
+
CLAUDE_PROJECT_DIR: "/mock/project",
|
|
78
|
+
SERVICE_REGISTRY_PATH: "/mock/project/service-registry.json",
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
expect(result.systemPrompt).toContain("project_service_catalog");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
describe('Quality check hooks graceful no-op', () => {
|
|
8
|
+
it('quality-check.cjs exits 0 when tsconfig/tooling is absent', async () => {
|
|
9
|
+
const temp = await mkdtemp(path.join(tmpdir(), 'xtrm-qg-node-'));
|
|
10
|
+
try {
|
|
11
|
+
await writeFile(path.join(temp, 'a.js'), 'const x = 1;\n', 'utf8');
|
|
12
|
+
|
|
13
|
+
const nodeHookPath = path.resolve(__dirname, '../../../hooks/quality-check.cjs');
|
|
14
|
+
const result = spawnSync('node', [nodeHookPath], {
|
|
15
|
+
cwd: temp,
|
|
16
|
+
encoding: 'utf8',
|
|
17
|
+
input: JSON.stringify({ tool_input: { path: 'a.js' } }),
|
|
18
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: temp },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(result.status).toBe(0);
|
|
22
|
+
} finally {
|
|
23
|
+
await rm(temp, { recursive: true, force: true });
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('quality-check.py exits 0 when pyproject/.python-version is absent', async () => {
|
|
28
|
+
const temp = await mkdtemp(path.join(tmpdir(), 'xtrm-qg-py-'));
|
|
29
|
+
try {
|
|
30
|
+
await writeFile(path.join(temp, 'a.py'), 'print("hello")\n', 'utf8');
|
|
31
|
+
|
|
32
|
+
const pythonHookPath = path.resolve(__dirname, '../../../hooks/quality-check.py');
|
|
33
|
+
const result = spawnSync('python3', [pythonHookPath], {
|
|
34
|
+
cwd: temp,
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
input: JSON.stringify({ tool_input: { path: 'a.py' } }),
|
|
37
|
+
env: { ...process.env, CLAUDE_PROJECT_DIR: temp },
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(result.status).toBe(0);
|
|
41
|
+
} finally {
|
|
42
|
+
await rm(temp, { recursive: true, force: true });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
});
|