@juicesharp/rpiv-pi 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,194 @@
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ mkdirSync,
5
+ mkdtempSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
14
+ import { BUNDLED_AGENTS_DIR, syncBundledAgents } from "./agents.js";
15
+
16
+ let cwd: string;
17
+ let targetDir: string;
18
+ let manifestPath: string;
19
+
20
+ beforeEach(() => {
21
+ cwd = mkdtempSync(join(tmpdir(), "rpiv-agents-"));
22
+ targetDir = join(cwd, ".pi", "agents");
23
+ manifestPath = join(targetDir, ".rpiv-managed.json");
24
+ });
25
+ afterEach(() => {
26
+ rmSync(cwd, { recursive: true, force: true });
27
+ vi.restoreAllMocks();
28
+ });
29
+
30
+ describe("syncBundledAgents — first run (empty target)", () => {
31
+ it("copies every source .md and writes manifest", () => {
32
+ const r = syncBundledAgents(cwd, false);
33
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
34
+ expect(r.added.sort()).toEqual(bundled.sort());
35
+ expect(r.updated).toEqual([]);
36
+ expect(r.errors).toEqual([]);
37
+ expect(existsSync(manifestPath)).toBe(true);
38
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
39
+ expect(manifest.sort()).toEqual(bundled.sort());
40
+ });
41
+ });
42
+
43
+ describe("syncBundledAgents — bootstrap-claim from manifest-less drift", () => {
44
+ it("claims pre-existing files matching bundled names as managed", () => {
45
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
46
+ if (bundled.length === 0) return;
47
+ mkdirSync(targetDir, { recursive: true });
48
+ writeFileSync(join(targetDir, bundled[0]), "drift content", "utf-8");
49
+ const r = syncBundledAgents(cwd, false);
50
+ expect(r.pendingUpdate).toContain(bundled[0]);
51
+ expect(readFileSync(join(targetDir, bundled[0]), "utf-8")).toBe("drift content");
52
+ });
53
+ });
54
+
55
+ describe("syncBundledAgents — apply=false (detect only)", () => {
56
+ it("reports pendingUpdate for changed managed files without touching them", () => {
57
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
58
+ if (bundled.length === 0) return;
59
+ syncBundledAgents(cwd, true);
60
+ writeFileSync(join(targetDir, bundled[0]), "user-modified", "utf-8");
61
+ const r = syncBundledAgents(cwd, false);
62
+ expect(r.pendingUpdate).toContain(bundled[0]);
63
+ expect(readFileSync(join(targetDir, bundled[0]), "utf-8")).toBe("user-modified");
64
+ });
65
+ });
66
+
67
+ describe("syncBundledAgents — apply=true (mutating sync)", () => {
68
+ it("overwrites changed managed files", () => {
69
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
70
+ if (bundled.length === 0) return;
71
+ syncBundledAgents(cwd, true);
72
+ writeFileSync(join(targetDir, bundled[0]), "user-modified", "utf-8");
73
+ const r = syncBundledAgents(cwd, true);
74
+ expect(r.updated).toContain(bundled[0]);
75
+ const srcContent = readFileSync(join(BUNDLED_AGENTS_DIR, bundled[0]), "utf-8");
76
+ expect(readFileSync(join(targetDir, bundled[0]), "utf-8")).toBe(srcContent);
77
+ });
78
+
79
+ it("removes stale managed files absent from source", () => {
80
+ mkdirSync(targetDir, { recursive: true });
81
+ writeFileSync(join(targetDir, "stale.md"), "x", "utf-8");
82
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
83
+ const r = syncBundledAgents(cwd, true);
84
+ expect(r.removed).toContain("stale.md");
85
+ expect(existsSync(join(targetDir, "stale.md"))).toBe(false);
86
+ });
87
+
88
+ it("leaves unchanged managed files alone", () => {
89
+ syncBundledAgents(cwd, true);
90
+ const r = syncBundledAgents(cwd, true);
91
+ expect(r.updated).toEqual([]);
92
+ expect(r.unchanged.length).toBeGreaterThan(0);
93
+ });
94
+ });
95
+
96
+ describe("syncBundledAgents — error paths", () => {
97
+ it.skipIf(process.platform === "win32")("collects copy error when dest is read-only", () => {
98
+ // Create a read-only target dir so copyFileSync fails with EACCES/EPERM
99
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
100
+ if (bundled.length === 0) return;
101
+ mkdirSync(targetDir, { recursive: true });
102
+ chmodSync(targetDir, 0o500);
103
+ try {
104
+ const r = syncBundledAgents(cwd, false);
105
+ // At least one copy op should have failed; otherwise nothing proves the error path
106
+ const errorTripped = r.errors.some((e) => e.op === "copy") || r.added.length < bundled.length;
107
+ expect(errorTripped).toBe(true);
108
+ } finally {
109
+ chmodSync(targetDir, 0o700);
110
+ }
111
+ });
112
+ });
113
+
114
+ describe("syncBundledAgents — stale-file detection (apply=false)", () => {
115
+ it("reports pendingRemove when a managed file has no matching source", () => {
116
+ mkdirSync(targetDir, { recursive: true });
117
+ writeFileSync(join(targetDir, "stale.md"), "x", "utf-8");
118
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
119
+ const r = syncBundledAgents(cwd, false);
120
+ expect(r.pendingRemove).toContain("stale.md");
121
+ expect(r.removed).toEqual([]);
122
+ expect(existsSync(join(targetDir, "stale.md"))).toBe(true);
123
+ });
124
+
125
+ it("keeps pendingRemove entries in the manifest so the next apply can finish removal", () => {
126
+ mkdirSync(targetDir, { recursive: true });
127
+ writeFileSync(join(targetDir, "stale.md"), "x", "utf-8");
128
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
129
+ syncBundledAgents(cwd, false);
130
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as string[];
131
+ expect(manifest).toContain("stale.md");
132
+ });
133
+
134
+ it("skips pendingRemove when the stale file no longer exists on disk", () => {
135
+ mkdirSync(targetDir, { recursive: true });
136
+ // Manifest claims stale.md but disk does not have it
137
+ writeFileSync(manifestPath, JSON.stringify(["stale.md"]), "utf-8");
138
+ const r = syncBundledAgents(cwd, false);
139
+ expect(r.pendingRemove).not.toContain("stale.md");
140
+ expect(r.removed).not.toContain("stale.md");
141
+ });
142
+ });
143
+
144
+ describe("syncBundledAgents — manifest robustness", () => {
145
+ it("treats a corrupt manifest (invalid JSON) as empty and re-bootstraps", () => {
146
+ mkdirSync(targetDir, { recursive: true });
147
+ writeFileSync(manifestPath, "{ not json ::", "utf-8");
148
+ const r = syncBundledAgents(cwd, false);
149
+ expect(r.errors).toEqual([]);
150
+ // After sync, the manifest should be valid JSON again.
151
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as string[];
152
+ expect(Array.isArray(manifest)).toBe(true);
153
+ });
154
+
155
+ it("treats a non-array manifest as empty and re-bootstraps", () => {
156
+ mkdirSync(targetDir, { recursive: true });
157
+ writeFileSync(manifestPath, JSON.stringify({ oops: true }), "utf-8");
158
+ const r = syncBundledAgents(cwd, false);
159
+ expect(r.errors).toEqual([]);
160
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8")) as string[];
161
+ expect(Array.isArray(manifest)).toBe(true);
162
+ });
163
+
164
+ it("filters non-string manifest entries during parse", () => {
165
+ mkdirSync(targetDir, { recursive: true });
166
+ writeFileSync(join(targetDir, "unrelated.md"), "keep me", "utf-8");
167
+ // Write manifest containing mixed types (must be ignored per-entry rather than whole-file)
168
+ writeFileSync(manifestPath, JSON.stringify([42, null, "unrelated.md"]), "utf-8");
169
+ const r = syncBundledAgents(cwd, false);
170
+ expect(r.errors).toEqual([]);
171
+ // unrelated.md is not in source, so it will be tracked for pendingRemove
172
+ expect(r.pendingRemove).toContain("unrelated.md");
173
+ });
174
+ });
175
+
176
+ describe("syncBundledAgents — subsequent-run bookkeeping", () => {
177
+ it("reports unchanged (not added) on a second run with no changes", () => {
178
+ syncBundledAgents(cwd, true);
179
+ const r = syncBundledAgents(cwd, false);
180
+ expect(r.added).toEqual([]);
181
+ expect(r.updated).toEqual([]);
182
+ expect(r.pendingUpdate).toEqual([]);
183
+ expect(r.unchanged.length).toBeGreaterThan(0);
184
+ });
185
+
186
+ it("treats a destination file that was manually removed as a new add on next sync", () => {
187
+ syncBundledAgents(cwd, true);
188
+ const bundled = readdirSync(BUNDLED_AGENTS_DIR).filter((f) => f.endsWith(".md"));
189
+ if (bundled.length === 0) return;
190
+ rmSync(join(targetDir, bundled[0]));
191
+ const r = syncBundledAgents(cwd, false);
192
+ expect(r.added).toContain(bundled[0]);
193
+ });
194
+ });
@@ -0,0 +1,14 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { FLAG_DEBUG, MSG_TYPE_GIT_CONTEXT, MSG_TYPE_GUIDANCE } from "./constants.js";
3
+
4
+ describe("rpiv-core constants", () => {
5
+ it("FLAG_DEBUG is the canonical debug-flag name", () => {
6
+ expect(FLAG_DEBUG).toBe("rpiv-debug");
7
+ });
8
+ it("MSG_TYPE_GIT_CONTEXT is the canonical git-context message type", () => {
9
+ expect(MSG_TYPE_GIT_CONTEXT).toBe("rpiv-git-context");
10
+ });
11
+ it("MSG_TYPE_GUIDANCE is the canonical guidance message type", () => {
12
+ expect(MSG_TYPE_GUIDANCE).toBe("rpiv-guidance");
13
+ });
14
+ });
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { isGitMutatingCommand } from "./git-context.js";
3
+
4
+ describe("isGitMutatingCommand — positives", () => {
5
+ const mutating = [
6
+ "git checkout main",
7
+ "git switch feature",
8
+ "git commit -m 'x'",
9
+ "git merge main",
10
+ "git rebase main",
11
+ "git pull",
12
+ "git reset --hard HEAD",
13
+ "git revert abc",
14
+ "git cherry-pick abc",
15
+ "git worktree add ../wt",
16
+ "git am < patch",
17
+ "git stash",
18
+ ];
19
+ for (const cmd of mutating) {
20
+ it(`matches: ${cmd}`, () => {
21
+ expect(isGitMutatingCommand(cmd)).toBe(true);
22
+ });
23
+ }
24
+ it("matches when chained with preceding command", () => {
25
+ expect(isGitMutatingCommand("cd x && git commit")).toBe(true);
26
+ });
27
+ });
28
+
29
+ describe("isGitMutatingCommand — negatives", () => {
30
+ const nonMutating = [
31
+ "git status",
32
+ "git log",
33
+ "git diff",
34
+ "git rev-parse HEAD",
35
+ "git config user.name",
36
+ "gitmoji commit",
37
+ "git --version",
38
+ ];
39
+ for (const cmd of nonMutating) {
40
+ it(`does NOT match: ${cmd}`, () => {
41
+ expect(isGitMutatingCommand(cmd)).toBe(false);
42
+ });
43
+ }
44
+ it("rejects empty string", () => {
45
+ expect(isGitMutatingCommand("")).toBe(false);
46
+ });
47
+ });
@@ -0,0 +1,109 @@
1
+ import { createMockPi, stubGitExec } from "@juicesharp/rpiv-test-utils";
2
+ import { beforeEach, describe, expect, it } from "vitest";
3
+ import { clearGitContextCache, getGitContext, resetInjectedMarker, takeGitContextIfChanged } from "./git-context.js";
4
+
5
+ beforeEach(() => {
6
+ clearGitContextCache();
7
+ resetInjectedMarker();
8
+ });
9
+
10
+ describe("getGitContext", () => {
11
+ it("parses branch + commit + user from three exec calls", async () => {
12
+ const { pi } = createMockPi({
13
+ exec: stubGitExec({ branch: "main", commit: "abc1234", user: "alice" }) as never,
14
+ });
15
+ const ctx = await getGitContext(pi);
16
+ expect(ctx).toEqual({ branch: "main", commit: "abc1234", user: "alice" });
17
+ });
18
+
19
+ it("remaps literal HEAD to 'detached'", async () => {
20
+ const { pi } = createMockPi({
21
+ exec: stubGitExec({ branch: "HEAD", commit: "abc", user: "alice" }) as never,
22
+ });
23
+ const ctx = await getGitContext(pi);
24
+ expect(ctx?.branch).toBe("detached");
25
+ });
26
+
27
+ it("returns null when both branch and commit are empty (not a repo)", async () => {
28
+ const { pi } = createMockPi({ exec: stubGitExec({}) as never });
29
+ expect(await getGitContext(pi)).toBeNull();
30
+ });
31
+
32
+ it("falls back to process.env.USER when git config user.name errors", async () => {
33
+ const { pi } = createMockPi({
34
+ exec: stubGitExec({ branch: "main", commit: "abc", userError: new Error("no config") }) as never,
35
+ });
36
+ process.env.USER = "env-alice";
37
+ const ctx = await getGitContext(pi);
38
+ expect(ctx?.user).toBe("env-alice");
39
+ });
40
+
41
+ it("falls back to 'unknown' when neither git nor env has user", async () => {
42
+ const origUser = process.env.USER;
43
+ delete process.env.USER;
44
+ try {
45
+ const { pi } = createMockPi({
46
+ exec: stubGitExec({ branch: "main", commit: "abc", userError: new Error("x") }) as never,
47
+ });
48
+ const ctx = await getGitContext(pi);
49
+ expect(ctx?.user).toBe("unknown");
50
+ } finally {
51
+ if (origUser) process.env.USER = origUser;
52
+ }
53
+ });
54
+
55
+ it("memoises: subsequent calls do not re-exec", async () => {
56
+ const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
57
+ const { pi } = createMockPi({ exec: exec as never });
58
+ await getGitContext(pi);
59
+ await getGitContext(pi);
60
+ expect(exec).toHaveBeenCalledTimes(3); // 3 initial exec calls, no second-round
61
+ });
62
+
63
+ it("clearGitContextCache forces re-read", async () => {
64
+ const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
65
+ const { pi } = createMockPi({ exec: exec as never });
66
+ await getGitContext(pi);
67
+ clearGitContextCache();
68
+ await getGitContext(pi);
69
+ expect(exec).toHaveBeenCalledTimes(6);
70
+ });
71
+ });
72
+
73
+ describe("takeGitContextIfChanged", () => {
74
+ it("returns the context-line on first call", async () => {
75
+ const { pi } = createMockPi({
76
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
77
+ });
78
+ const r = await takeGitContextIfChanged(pi);
79
+ expect(r).toContain("- Branch: main");
80
+ expect(r).toContain("- Commit: abc");
81
+ expect(r).toContain("- User: alice");
82
+ });
83
+
84
+ it("returns null on second call when signature unchanged", async () => {
85
+ const { pi } = createMockPi({
86
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
87
+ });
88
+ await takeGitContextIfChanged(pi);
89
+ expect(await takeGitContextIfChanged(pi)).toBeNull();
90
+ });
91
+
92
+ it("re-emits after clearGitContextCache + resetInjectedMarker + signature change", async () => {
93
+ const { pi } = createMockPi({
94
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
95
+ });
96
+ await takeGitContextIfChanged(pi);
97
+ clearGitContextCache();
98
+ resetInjectedMarker();
99
+ const { pi: pi2 } = createMockPi({
100
+ exec: stubGitExec({ branch: "feature", commit: "def", user: "alice" }) as never,
101
+ });
102
+ expect(await takeGitContextIfChanged(pi2)).not.toBeNull();
103
+ });
104
+
105
+ it("returns null when not in a git repo", async () => {
106
+ const { pi } = createMockPi({ exec: stubGitExec({}) as never });
107
+ expect(await takeGitContextIfChanged(pi)).toBeNull();
108
+ });
109
+ });
@@ -0,0 +1,136 @@
1
+ import { mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createMockPi, writeGuidanceTree } from "@juicesharp/rpiv-test-utils";
5
+ import { afterEach, beforeEach, describe, expect, it, type vi } from "vitest";
6
+ import { clearInjectionState, handleToolCallGuidance, injectRootGuidance, resolveGuidance } from "./guidance.js";
7
+
8
+ let projectDir: string;
9
+
10
+ beforeEach(() => {
11
+ projectDir = mkdtempSync(join(tmpdir(), "rpiv-guidance-"));
12
+ clearInjectionState();
13
+ });
14
+ afterEach(() => {
15
+ rmSync(projectDir, { recursive: true, force: true });
16
+ });
17
+
18
+ describe("resolveGuidance — ladder", () => {
19
+ it("AGENTS.md > CLAUDE.md > architecture.md at depth > 0", () => {
20
+ writeGuidanceTree(projectDir, {
21
+ "src/AGENTS.md": "agents-body",
22
+ "src/CLAUDE.md": "claude-body",
23
+ ".rpiv/guidance/src/architecture.md": "arch-body",
24
+ });
25
+ const resolved = resolveGuidance(join(projectDir, "src", "foo.ts"), projectDir);
26
+ const srcEntry = resolved.find((r) => r.relativePath.startsWith("src/"));
27
+ expect(srcEntry?.kind).toBe("agents");
28
+ });
29
+
30
+ it("depth 0 skips AGENTS/CLAUDE but keeps root architecture.md", () => {
31
+ writeGuidanceTree(projectDir, {
32
+ "AGENTS.md": "root-agents",
33
+ ".rpiv/guidance/architecture.md": "root-arch",
34
+ });
35
+ const resolved = resolveGuidance(join(projectDir, "any", "file.ts"), projectDir);
36
+ const rootEntry = resolved.find((r) => r.relativePath === ".rpiv/guidance/architecture.md");
37
+ expect(rootEntry?.kind).toBe("architecture");
38
+ expect(resolved.some((r) => r.relativePath === "AGENTS.md")).toBe(false);
39
+ });
40
+
41
+ it("returns root-first, specific-last order", () => {
42
+ writeGuidanceTree(projectDir, {
43
+ ".rpiv/guidance/architecture.md": "root",
44
+ "a/AGENTS.md": "a",
45
+ "a/b/AGENTS.md": "ab",
46
+ });
47
+ const resolved = resolveGuidance(join(projectDir, "a", "b", "c.ts"), projectDir);
48
+ expect(resolved.map((r) => r.content)).toEqual(["root", "a", "ab"]);
49
+ });
50
+
51
+ it("returns empty when file is outside projectDir", () => {
52
+ expect(resolveGuidance("/totally/elsewhere/foo.ts", projectDir)).toEqual([]);
53
+ });
54
+
55
+ it("returns empty when nothing exists along the ladder", () => {
56
+ expect(resolveGuidance(join(projectDir, "x.ts"), projectDir)).toEqual([]);
57
+ });
58
+ });
59
+
60
+ describe("injectRootGuidance", () => {
61
+ it("sends root architecture.md when present", () => {
62
+ writeGuidanceTree(projectDir, { ".rpiv/guidance/architecture.md": "body" });
63
+ const { pi } = createMockPi();
64
+ injectRootGuidance(projectDir, pi);
65
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
66
+ expect((pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content).toContain("body");
67
+ });
68
+
69
+ it("is idempotent across calls within a session", () => {
70
+ writeGuidanceTree(projectDir, { ".rpiv/guidance/architecture.md": "body" });
71
+ const { pi } = createMockPi();
72
+ injectRootGuidance(projectDir, pi);
73
+ injectRootGuidance(projectDir, pi);
74
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("re-injects after clearInjectionState", () => {
78
+ writeGuidanceTree(projectDir, { ".rpiv/guidance/architecture.md": "body" });
79
+ const { pi } = createMockPi();
80
+ injectRootGuidance(projectDir, pi);
81
+ clearInjectionState();
82
+ injectRootGuidance(projectDir, pi);
83
+ expect(pi.sendMessage).toHaveBeenCalledTimes(2);
84
+ });
85
+
86
+ it("no-ops when root architecture.md is missing", () => {
87
+ const { pi } = createMockPi();
88
+ injectRootGuidance(projectDir, pi);
89
+ expect(pi.sendMessage).not.toHaveBeenCalled();
90
+ });
91
+ });
92
+
93
+ describe("handleToolCallGuidance", () => {
94
+ it("skips non-read/edit/write tools", () => {
95
+ const { pi } = createMockPi();
96
+ handleToolCallGuidance({ toolName: "bash", input: {} }, { cwd: projectDir }, pi);
97
+ expect(pi.sendMessage).not.toHaveBeenCalled();
98
+ });
99
+
100
+ it("dedupes per-file across multiple tool_calls", () => {
101
+ writeGuidanceTree(projectDir, { "src/AGENTS.md": "a" });
102
+ const { pi } = createMockPi();
103
+ const ev = { toolName: "read", input: { file_path: join(projectDir, "src", "x.ts") } };
104
+ handleToolCallGuidance(ev, { cwd: projectDir }, pi);
105
+ handleToolCallGuidance(ev, { cwd: projectDir }, pi);
106
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
107
+ });
108
+
109
+ it("supports both 'path' and 'file_path' input keys", () => {
110
+ writeGuidanceTree(projectDir, { "src/AGENTS.md": "a" });
111
+ const { pi } = createMockPi();
112
+ handleToolCallGuidance(
113
+ { toolName: "edit", input: { path: join(projectDir, "src", "x.ts") } },
114
+ { cwd: projectDir },
115
+ pi,
116
+ );
117
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it("emits one sendMessage combining multiple newly-resolved files", () => {
121
+ writeGuidanceTree(projectDir, {
122
+ ".rpiv/guidance/architecture.md": "root",
123
+ "src/AGENTS.md": "src",
124
+ });
125
+ const { pi } = createMockPi();
126
+ handleToolCallGuidance(
127
+ { toolName: "write", input: { file_path: join(projectDir, "src", "x.ts") } },
128
+ { cwd: projectDir },
129
+ pi,
130
+ );
131
+ expect(pi.sendMessage).toHaveBeenCalledTimes(1);
132
+ const content = (pi.sendMessage as ReturnType<typeof vi.fn>).mock.calls[0][0].content;
133
+ expect(content).toContain("root");
134
+ expect(content).toContain("src");
135
+ });
136
+ });
@@ -0,0 +1,59 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { describe, expect, it } from "vitest";
4
+ import { findMissingSiblings } from "./package-checks.js";
5
+ import { SIBLINGS } from "./siblings.js";
6
+
7
+ const SETTINGS_PATH = join(process.env.HOME!, ".pi", "agent", "settings.json");
8
+
9
+ function writeSettings(contents: unknown) {
10
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
11
+ writeFileSync(SETTINGS_PATH, JSON.stringify(contents), "utf-8");
12
+ }
13
+
14
+ describe("findMissingSiblings", () => {
15
+ it("returns all 7 siblings when settings.json is missing", () => {
16
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
17
+ });
18
+
19
+ it("returns all 7 siblings when JSON is invalid", () => {
20
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
21
+ writeFileSync(SETTINGS_PATH, "{not json", "utf-8");
22
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
23
+ });
24
+
25
+ it("returns all 7 siblings when packages field is absent", () => {
26
+ writeSettings({ other: "data" });
27
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
28
+ });
29
+
30
+ it("returns all 7 siblings when packages is not an array", () => {
31
+ writeSettings({ packages: "not-array" });
32
+ expect(findMissingSiblings()).toHaveLength(SIBLINGS.length);
33
+ });
34
+
35
+ it("filters out non-string entries defensively", () => {
36
+ writeSettings({ packages: [null, 42, "@juicesharp/rpiv-todo"] });
37
+ const missing = findMissingSiblings();
38
+ expect(missing.find((s) => s.matches.test("@juicesharp/rpiv-todo"))).toBeUndefined();
39
+ });
40
+
41
+ it("matches case-insensitively", () => {
42
+ writeSettings({ packages: ["@JUICESHARP/RPIV-TODO"] });
43
+ const missing = findMissingSiblings();
44
+ expect(missing.find((s) => s.matches.test("@juicesharp/rpiv-todo"))).toBeUndefined();
45
+ });
46
+
47
+ it("rpiv-args word-boundary: treats rpiv-args-extended as non-install", () => {
48
+ writeSettings({ packages: ["@juicesharp/rpiv-args-extended"] });
49
+ const missing = findMissingSiblings();
50
+ expect(missing.find((s) => s.pkg.endsWith("/rpiv-args"))).toBeDefined();
51
+ });
52
+
53
+ it("returns [] when all 7 siblings are installed", () => {
54
+ writeSettings({
55
+ packages: SIBLINGS.map((s) => s.pkg.replace(/^npm:/, "")),
56
+ });
57
+ expect(findMissingSiblings()).toEqual([]);
58
+ });
59
+ });
@@ -0,0 +1,100 @@
1
+ import { makeSpawnStub } from "@juicesharp/rpiv-test-utils";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("node:child_process", () => ({ spawn: vi.fn() }));
5
+
6
+ import { spawn } from "node:child_process";
7
+ import { spawnPiInstall } from "./pi-installer.js";
8
+
9
+ beforeEach(() => {
10
+ vi.mocked(spawn).mockReset();
11
+ });
12
+
13
+ describe("spawnPiInstall — success path", () => {
14
+ it("resolves with exit 0 + buffered stdout/stderr", async () => {
15
+ vi.mocked(spawn).mockImplementationOnce(
16
+ () => makeSpawnStub({ stdout: "installed\n", stderr: "", exitCode: 0 }) as unknown as ReturnType<typeof spawn>,
17
+ );
18
+ const r = await spawnPiInstall("@x/y", 30_000);
19
+ expect(r).toEqual({ code: 0, stdout: "installed\n", stderr: "" });
20
+ });
21
+ });
22
+
23
+ describe("spawnPiInstall — non-zero exit", () => {
24
+ it("returns exit code and accumulated stderr", async () => {
25
+ vi.mocked(spawn).mockImplementationOnce(
26
+ () => makeSpawnStub({ stdout: "", stderr: "fail\n", exitCode: 2 }) as unknown as ReturnType<typeof spawn>,
27
+ );
28
+ const r = await spawnPiInstall("@x/y", 30_000);
29
+ expect(r.code).toBe(2);
30
+ expect(r.stderr).toBe("fail\n");
31
+ });
32
+
33
+ it("fallback code=1 when close emits null", async () => {
34
+ const stub = makeSpawnStub({ neverSettles: true });
35
+ vi.mocked(spawn).mockImplementationOnce(() => stub as unknown as ReturnType<typeof spawn>);
36
+ const promise = spawnPiInstall("@x/y", 30_000);
37
+ stub.emit("close", null);
38
+ const r = await promise;
39
+ expect(r.code).toBe(1);
40
+ });
41
+ });
42
+
43
+ describe("spawnPiInstall — error event before close", () => {
44
+ it("settles with code=1 + error.message in stderr", async () => {
45
+ vi.mocked(spawn).mockImplementationOnce(
46
+ () => makeSpawnStub({ error: new Error("ENOENT pi") }) as unknown as ReturnType<typeof spawn>,
47
+ );
48
+ const r = await spawnPiInstall("@x/y", 30_000);
49
+ expect(r.code).toBe(1);
50
+ expect(r.stderr).toContain("ENOENT pi");
51
+ });
52
+ });
53
+
54
+ describe("spawnPiInstall — timeout", () => {
55
+ it("kills with SIGTERM at timeout and resolves with code 124", async () => {
56
+ vi.useFakeTimers();
57
+ const stub = makeSpawnStub({ neverSettles: true });
58
+ const killSpy = vi.spyOn(stub, "kill");
59
+ vi.mocked(spawn).mockImplementationOnce(() => stub as unknown as ReturnType<typeof spawn>);
60
+ const promise = spawnPiInstall("@x/y", 30_000);
61
+ await vi.advanceTimersByTimeAsync(30_000);
62
+ vi.useRealTimers();
63
+ const r = await promise;
64
+ expect(killSpy).toHaveBeenCalledWith("SIGTERM");
65
+ expect(r.code).toBe(124);
66
+ expect(r.stderr).toContain("timed out");
67
+ });
68
+ });
69
+
70
+ describe("spawnPiInstall — settle idempotence", () => {
71
+ it("only resolves once even if close fires after timeout", async () => {
72
+ vi.useFakeTimers();
73
+ const stub = makeSpawnStub({ neverSettles: true });
74
+ vi.mocked(spawn).mockImplementationOnce(() => stub as unknown as ReturnType<typeof spawn>);
75
+ const promise = spawnPiInstall("@x/y", 30_000);
76
+ await vi.advanceTimersByTimeAsync(30_000);
77
+ stub.emit("close", 0); // late close — must not replace the timeout result
78
+ vi.useRealTimers();
79
+ const r = await promise;
80
+ expect(r.code).toBe(124);
81
+ });
82
+ });
83
+
84
+ describe("spawnPiInstall — Windows branch", () => {
85
+ it("invokes via cmd.exe /c pi install on win32", async () => {
86
+ const origPlatform = process.platform;
87
+ Object.defineProperty(process, "platform", { value: "win32", configurable: true });
88
+ try {
89
+ vi.mocked(spawn).mockImplementationOnce(
90
+ () => makeSpawnStub({ exitCode: 0 }) as unknown as ReturnType<typeof spawn>,
91
+ );
92
+ await spawnPiInstall("@x/y", 30_000);
93
+ const firstCall = vi.mocked(spawn).mock.calls[0];
94
+ expect(firstCall[0]).toBe("cmd.exe");
95
+ expect(firstCall[1]).toEqual(["/c", "pi", "install", "@x/y"]);
96
+ } finally {
97
+ Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
98
+ }
99
+ });
100
+ });
@@ -0,0 +1,197 @@
1
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { createMockCtx, createMockPi, stubGitExec } from "@juicesharp/rpiv-test-utils";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ vi.mock("./package-checks.js", () => ({ findMissingSiblings: vi.fn(() => []) }));
8
+ vi.mock("./agents.js", async (importOriginal) => {
9
+ const actual = await importOriginal<typeof import("./agents.js")>();
10
+ return {
11
+ ...actual,
12
+ syncBundledAgents: vi.fn(() => ({
13
+ added: [],
14
+ updated: [],
15
+ unchanged: [],
16
+ removed: [],
17
+ pendingUpdate: [],
18
+ pendingRemove: [],
19
+ errors: [],
20
+ })),
21
+ };
22
+ });
23
+
24
+ import type { SyncResult } from "./agents.js";
25
+ import { syncBundledAgents } from "./agents.js";
26
+ import { clearGitContextCache, getGitContext, resetInjectedMarker, takeGitContextIfChanged } from "./git-context.js";
27
+ import { clearInjectionState } from "./guidance.js";
28
+ import { findMissingSiblings } from "./package-checks.js";
29
+ import { registerSessionHooks } from "./session-hooks.js";
30
+
31
+ const emptySync: SyncResult = {
32
+ added: [],
33
+ updated: [],
34
+ unchanged: [],
35
+ removed: [],
36
+ pendingUpdate: [],
37
+ pendingRemove: [],
38
+ errors: [],
39
+ };
40
+
41
+ let projectDir: string;
42
+
43
+ beforeEach(() => {
44
+ projectDir = mkdtempSync(join(tmpdir(), "rpiv-session-"));
45
+ clearInjectionState();
46
+ clearGitContextCache();
47
+ resetInjectedMarker();
48
+ });
49
+ afterEach(() => {
50
+ rmSync(projectDir, { recursive: true, force: true });
51
+ });
52
+
53
+ describe("registerSessionHooks — event wiring", () => {
54
+ it("registers 5 events", () => {
55
+ const { pi, captured } = createMockPi();
56
+ registerSessionHooks(pi);
57
+ for (const ev of ["session_start", "session_compact", "session_shutdown", "tool_call", "before_agent_start"]) {
58
+ expect(captured.events.has(ev)).toBe(true);
59
+ }
60
+ });
61
+ });
62
+
63
+ describe("session_start hook", () => {
64
+ it("scaffolds thoughts dirs under ctx.cwd", async () => {
65
+ const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
66
+ registerSessionHooks(pi);
67
+ const handler = captured.events.get("session_start")?.[0];
68
+ const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
69
+ await handler?.({ reason: "startup" } as never, ctx as never);
70
+ for (const d of [
71
+ "thoughts/shared/research",
72
+ "thoughts/shared/questions",
73
+ "thoughts/shared/designs",
74
+ "thoughts/shared/plans",
75
+ "thoughts/shared/handoffs",
76
+ "thoughts/shared/reviews",
77
+ ]) {
78
+ expect(existsSync(join(projectDir, d))).toBe(true);
79
+ }
80
+ });
81
+ });
82
+
83
+ describe("session_start hook — notifications", () => {
84
+ it("emits 'Copied N agents' info when added > 0", async () => {
85
+ vi.mocked(syncBundledAgents).mockReturnValueOnce({ ...emptySync, added: ["a.md", "b.md"] });
86
+ vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
87
+ const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
88
+ registerSessionHooks(pi);
89
+ const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
90
+ await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
91
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringMatching(/Copied 2 rpiv-pi agent/), "info");
92
+ });
93
+
94
+ it("emits a single drift line combining pendingUpdate + pendingRemove", async () => {
95
+ vi.mocked(syncBundledAgents).mockReturnValueOnce({
96
+ ...emptySync,
97
+ pendingUpdate: ["a.md"],
98
+ pendingRemove: ["b.md", "c.md"],
99
+ });
100
+ vi.mocked(findMissingSiblings).mockReturnValueOnce([]);
101
+ const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
102
+ registerSessionHooks(pi);
103
+ const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
104
+ await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
105
+ const driftCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find(
106
+ (c) => typeof c[0] === "string" && c[0].includes("outdated"),
107
+ );
108
+ expect(driftCall).toBeDefined();
109
+ expect(driftCall?.[0]).toContain("1 outdated");
110
+ expect(driftCall?.[0]).toContain("2 removed from bundle");
111
+ expect(driftCall?.[1]).toBe("info");
112
+ });
113
+
114
+ it("warns about missing siblings with npm: prefix stripped", async () => {
115
+ vi.mocked(syncBundledAgents).mockReturnValueOnce(emptySync);
116
+ vi.mocked(findMissingSiblings).mockReturnValueOnce([
117
+ { pkg: "npm:@juicesharp/rpiv-advisor", matches: /./, provides: "x" },
118
+ { pkg: "npm:@juicesharp/rpiv-args", matches: /./, provides: "y" },
119
+ ] as never);
120
+ const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
121
+ registerSessionHooks(pi);
122
+ const ctx = createMockCtx({ cwd: projectDir, hasUI: true });
123
+ await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
124
+ const warnCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.find((c) => c[1] === "warning");
125
+ expect(warnCall).toBeDefined();
126
+ expect(warnCall?.[0]).toContain("rpiv-pi requires 2 sibling");
127
+ expect(warnCall?.[0]).toContain("@juicesharp/rpiv-advisor");
128
+ expect(warnCall?.[0]).toContain("@juicesharp/rpiv-args");
129
+ expect(warnCall?.[0]).not.toContain("npm:");
130
+ });
131
+
132
+ it("skips notifications when !hasUI", async () => {
133
+ vi.mocked(syncBundledAgents).mockReturnValueOnce({ ...emptySync, added: ["a.md"] });
134
+ vi.mocked(findMissingSiblings).mockReturnValueOnce([
135
+ { pkg: "npm:@juicesharp/rpiv-todo", matches: /./, provides: "t" },
136
+ ] as never);
137
+ const { pi, captured } = createMockPi({ exec: stubGitExec({}) as never });
138
+ registerSessionHooks(pi);
139
+ const ctx = createMockCtx({ cwd: projectDir, hasUI: false });
140
+ await captured.events.get("session_start")?.[0]({ reason: "startup" } as never, ctx as never);
141
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
142
+ });
143
+ });
144
+
145
+ describe("session_shutdown hook", () => {
146
+ it("clears git-context cache and allows takeGitContextIfChanged to re-emit", async () => {
147
+ const exec = stubGitExec({ branch: "main", commit: "abc", user: "alice" });
148
+ const { pi, captured } = createMockPi({ exec: exec as never });
149
+ registerSessionHooks(pi);
150
+ await takeGitContextIfChanged(pi);
151
+ const callsBefore = exec.mock.calls.length;
152
+ await captured.events.get("session_shutdown")?.[0]({} as never, createMockCtx() as never);
153
+ const reemit = await takeGitContextIfChanged(pi);
154
+ expect(reemit).not.toBeNull();
155
+ expect(exec.mock.calls.length).toBeGreaterThan(callsBefore);
156
+ });
157
+ });
158
+
159
+ describe("tool_call hook", () => {
160
+ it("clears git-context cache on mutating bash command", async () => {
161
+ const exec = stubGitExec({ branch: "main", commit: "a", user: "u" });
162
+ const { pi, captured } = createMockPi({ exec: exec as never });
163
+ registerSessionHooks(pi);
164
+ const handler = captured.events.get("tool_call")?.[0];
165
+ const ctx = createMockCtx({ cwd: projectDir });
166
+ await getGitContext(pi);
167
+ const before = exec.mock.calls.length;
168
+ await handler?.({ toolName: "bash", input: { command: "git commit -m x" } } as never, ctx as never);
169
+ await getGitContext(pi);
170
+ expect(exec.mock.calls.length).toBeGreaterThan(before);
171
+ });
172
+ });
173
+
174
+ describe("before_agent_start hook", () => {
175
+ it("returns {message} on changed git sig", async () => {
176
+ const { pi, captured } = createMockPi({
177
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
178
+ });
179
+ registerSessionHooks(pi);
180
+ const handler = captured.events.get("before_agent_start")?.[0];
181
+ const ctx = createMockCtx({ cwd: projectDir });
182
+ const r = await handler?.({} as never, ctx as never);
183
+ expect(r).toHaveProperty("message");
184
+ });
185
+
186
+ it("returns undefined on dedup (signature unchanged)", async () => {
187
+ const { pi, captured } = createMockPi({
188
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
189
+ });
190
+ registerSessionHooks(pi);
191
+ const handler = captured.events.get("before_agent_start")?.[0];
192
+ const ctx = createMockCtx({ cwd: projectDir });
193
+ await handler?.({} as never, ctx as never);
194
+ const second = await handler?.({} as never, ctx as never);
195
+ expect(second).toBeUndefined();
196
+ });
197
+ });
@@ -0,0 +1,102 @@
1
+ import { createMockCtx, createMockPi } from "@juicesharp/rpiv-test-utils";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("./pi-installer.js", () => ({ spawnPiInstall: vi.fn() }));
5
+ vi.mock("./package-checks.js", () => ({ findMissingSiblings: vi.fn() }));
6
+
7
+ import { findMissingSiblings } from "./package-checks.js";
8
+ import { spawnPiInstall } from "./pi-installer.js";
9
+ import { registerSetupCommand } from "./setup-command.js";
10
+
11
+ beforeEach(() => {
12
+ vi.mocked(spawnPiInstall).mockReset();
13
+ vi.mocked(findMissingSiblings).mockReset();
14
+ });
15
+
16
+ describe("/rpiv-setup — command shape", () => {
17
+ it("registers under 'rpiv-setup'", () => {
18
+ const { pi, captured } = createMockPi();
19
+ registerSetupCommand(pi);
20
+ expect(captured.commands.has("rpiv-setup")).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe("/rpiv-setup — !hasUI", () => {
25
+ it("notifies error and exits", async () => {
26
+ const { pi, captured } = createMockPi();
27
+ registerSetupCommand(pi);
28
+ const ctx = createMockCtx({ hasUI: false });
29
+ await captured.commands.get("rpiv-setup")?.handler("", ctx as never);
30
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("interactive"), "error");
31
+ expect(spawnPiInstall).not.toHaveBeenCalled();
32
+ });
33
+ });
34
+
35
+ describe("/rpiv-setup — all installed", () => {
36
+ it("notifies all-installed info and exits", async () => {
37
+ vi.mocked(findMissingSiblings).mockReturnValue([]);
38
+ const { pi, captured } = createMockPi();
39
+ registerSetupCommand(pi);
40
+ const ctx = createMockCtx({ hasUI: true });
41
+ await captured.commands.get("rpiv-setup")?.handler("", ctx as never);
42
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("already installed"), "info");
43
+ });
44
+ });
45
+
46
+ describe("/rpiv-setup — user cancels", () => {
47
+ it("notifies cancelled info and skips installs", async () => {
48
+ vi.mocked(findMissingSiblings).mockReturnValue([{ pkg: "npm:@x/y", matches: /./, provides: "p" }]);
49
+ const { pi, captured } = createMockPi();
50
+ registerSetupCommand(pi);
51
+ const ctx = createMockCtx({ hasUI: true });
52
+ (ctx.ui.confirm as ReturnType<typeof vi.fn>).mockResolvedValueOnce(false);
53
+ await captured.commands.get("rpiv-setup")?.handler("", ctx as never);
54
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("cancelled"), "info");
55
+ expect(spawnPiInstall).not.toHaveBeenCalled();
56
+ });
57
+ });
58
+
59
+ describe("/rpiv-setup — mixed success/failure report", () => {
60
+ it("reports succeeded + failed with 300-char stderr snippets", async () => {
61
+ vi.mocked(findMissingSiblings).mockReturnValue([
62
+ { pkg: "npm:@x/a", matches: /./, provides: "A" },
63
+ { pkg: "npm:@x/b", matches: /./, provides: "B" },
64
+ ]);
65
+ vi.mocked(spawnPiInstall)
66
+ .mockResolvedValueOnce({ code: 0, stdout: "ok", stderr: "" })
67
+ .mockResolvedValueOnce({ code: 1, stdout: "", stderr: "x".repeat(500) });
68
+ const { pi, captured } = createMockPi();
69
+ registerSetupCommand(pi);
70
+ const ctx = createMockCtx({ hasUI: true });
71
+ await captured.commands.get("rpiv-setup")?.handler("", ctx as never);
72
+ const reportCall = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.at(-1);
73
+ const report: string = reportCall![0];
74
+ expect(report).toContain("npm:@x/a");
75
+ expect(report).toContain("npm:@x/b");
76
+ // stderr snippet capped at 300 chars
77
+ expect((report.match(/x+/g) ?? []).every((m) => m.length <= 300)).toBe(true);
78
+ expect(reportCall![1]).toBe("warning");
79
+ });
80
+
81
+ it("uses stdout fallback when stderr empty", async () => {
82
+ vi.mocked(findMissingSiblings).mockReturnValue([{ pkg: "npm:@x/a", matches: /./, provides: "A" }]);
83
+ vi.mocked(spawnPiInstall).mockResolvedValueOnce({ code: 1, stdout: "stdout-error", stderr: "" });
84
+ const { pi, captured } = createMockPi();
85
+ registerSetupCommand(pi);
86
+ const ctx = createMockCtx({ hasUI: true });
87
+ await captured.commands.get("rpiv-setup")?.handler("", ctx as never);
88
+ const report = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.at(-1)![0];
89
+ expect(report).toContain("stdout-error");
90
+ });
91
+
92
+ it("all-failed report omits Restart line", async () => {
93
+ vi.mocked(findMissingSiblings).mockReturnValue([{ pkg: "npm:@x/a", matches: /./, provides: "A" }]);
94
+ vi.mocked(spawnPiInstall).mockResolvedValueOnce({ code: 1, stdout: "", stderr: "err" });
95
+ const { pi, captured } = createMockPi();
96
+ registerSetupCommand(pi);
97
+ const ctx = createMockCtx({ hasUI: true });
98
+ await captured.commands.get("rpiv-setup")?.handler("", ctx as never);
99
+ const report = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls.at(-1)![0];
100
+ expect(report).not.toContain("Restart");
101
+ });
102
+ });
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { SIBLINGS } from "./siblings.js";
3
+
4
+ describe("SIBLINGS registry", () => {
5
+ it("contains 7 entries", () => {
6
+ expect(SIBLINGS).toHaveLength(7);
7
+ });
8
+
9
+ for (const s of SIBLINGS) {
10
+ it(`${s.pkg} — self-match against settings.json line shape`, () => {
11
+ expect(s.matches.test(s.pkg.replace(/^npm:/, ""))).toBe(true);
12
+ });
13
+ it(`${s.pkg} — case-insensitive match`, () => {
14
+ expect(s.matches.test(s.pkg.toUpperCase().replace(/^NPM:/, ""))).toBe(true);
15
+ });
16
+ }
17
+
18
+ it("rpiv-args does NOT match rpiv-args-extended (word boundary)", () => {
19
+ const argsEntry = SIBLINGS.find((s) => s.pkg.endsWith("/rpiv-args"));
20
+ expect(argsEntry).toBeDefined();
21
+ expect(argsEntry?.matches.test("@juicesharp/rpiv-args-extended")).toBe(false);
22
+ });
23
+
24
+ it("every entry has non-empty pkg + provides", () => {
25
+ for (const s of SIBLINGS) {
26
+ expect(s.pkg.length).toBeGreaterThan(0);
27
+ expect(s.provides.length).toBeGreaterThan(0);
28
+ }
29
+ });
30
+ });
@@ -0,0 +1,74 @@
1
+ import { createMockCtx, createMockPi } from "@juicesharp/rpiv-test-utils";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ vi.mock("./agents.js", () => ({
5
+ syncBundledAgents: vi.fn(),
6
+ }));
7
+
8
+ import { syncBundledAgents } from "./agents.js";
9
+ import { registerUpdateAgentsCommand } from "./update-agents-command.js";
10
+
11
+ beforeEach(() => {
12
+ vi.mocked(syncBundledAgents).mockReset();
13
+ });
14
+
15
+ const empty = (overrides: Partial<ReturnType<typeof syncBundledAgents>> = {}) => ({
16
+ added: [],
17
+ updated: [],
18
+ unchanged: [],
19
+ removed: [],
20
+ pendingUpdate: [],
21
+ pendingRemove: [],
22
+ errors: [],
23
+ ...overrides,
24
+ });
25
+
26
+ describe("/rpiv-update-agents", () => {
27
+ it("registers the command", () => {
28
+ const { pi, captured } = createMockPi();
29
+ registerUpdateAgentsCommand(pi);
30
+ expect(captured.commands.has("rpiv-update-agents")).toBe(true);
31
+ });
32
+
33
+ it("UP_TO_DATE when no changes, no errors", async () => {
34
+ vi.mocked(syncBundledAgents).mockReturnValue(empty());
35
+ const { pi, captured } = createMockPi();
36
+ registerUpdateAgentsCommand(pi);
37
+ const ctx = createMockCtx({ hasUI: true });
38
+ await captured.commands.get("rpiv-update-agents")?.handler("", ctx as never);
39
+ expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("up-to-date"), "info");
40
+ });
41
+
42
+ it("synced report when added+updated+removed > 0", async () => {
43
+ vi.mocked(syncBundledAgents).mockReturnValue(empty({ added: ["a.md"], updated: ["b.md"], removed: ["c.md"] }));
44
+ const { pi, captured } = createMockPi();
45
+ registerUpdateAgentsCommand(pi);
46
+ const ctx = createMockCtx({ hasUI: true });
47
+ await captured.commands.get("rpiv-update-agents")?.handler("", ctx as never);
48
+ const report = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls[0][0];
49
+ expect(report).toContain("1 added");
50
+ expect(report).toContain("1 updated");
51
+ expect(report).toContain("1 removed");
52
+ });
53
+
54
+ it("errors-only report uses 'warning' severity", async () => {
55
+ vi.mocked(syncBundledAgents).mockReturnValue(
56
+ empty({ errors: [{ op: "copy", message: "EACCES", file: "a.md" }] }),
57
+ );
58
+ const { pi, captured } = createMockPi();
59
+ registerUpdateAgentsCommand(pi);
60
+ const ctx = createMockCtx({ hasUI: true });
61
+ await captured.commands.get("rpiv-update-agents")?.handler("", ctx as never);
62
+ const [, severity] = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls[0];
63
+ expect(severity).toBe("warning");
64
+ });
65
+
66
+ it("stays silent when !hasUI", async () => {
67
+ vi.mocked(syncBundledAgents).mockReturnValue(empty({ added: ["x.md"] }));
68
+ const { pi, captured } = createMockPi();
69
+ registerUpdateAgentsCommand(pi);
70
+ const ctx = createMockCtx({ hasUI: false });
71
+ await captured.commands.get("rpiv-update-agents")?.handler("", ctx as never);
72
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
73
+ });
74
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-pi",
3
- "version": "0.10.0",
3
+ "version": "0.11.1",
4
4
  "description": "Skill-based development workflow for Pi Agent — discover, research, design, plan, implement, validate",
5
5
  "keywords": [
6
6
  "pi-package",