@juicesharp/rpiv-pi 1.9.1 → 1.10.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.
@@ -14,7 +14,6 @@ import { homedir, tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
16
16
  import {
17
- BUNDLED_AGENTS_DIR,
18
17
  CLEANUP_SKIP_REASON,
19
18
  cleanupPerCwdAgents,
20
19
  isSafeDestructiveOp,
@@ -22,6 +21,7 @@ import {
22
21
  summarizeCleanupSkips,
23
22
  syncBundledAgents,
24
23
  } from "./agents.js";
24
+ import { BUNDLED_AGENTS_DIR } from "./paths.js";
25
25
 
26
26
  const sha256 = (s: string | Buffer) => createHash("sha256").update(s).digest("hex");
27
27
 
@@ -26,27 +26,11 @@ import {
26
26
  unlinkSync,
27
27
  writeFileSync,
28
28
  } from "node:fs";
29
- import { dirname, isAbsolute, join, resolve, sep } from "node:path";
30
- import { fileURLToPath } from "node:url";
29
+ import { isAbsolute, join, resolve, sep } from "node:path";
31
30
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
31
+ import { BUNDLED_AGENTS_DIR } from "./paths.js";
32
32
  import { isPlainObject, toErrorMessage } from "./utils.js";
33
33
 
34
- // ---------------------------------------------------------------------------
35
- // Package-root resolution
36
- // ---------------------------------------------------------------------------
37
-
38
- /**
39
- * Resolves the rpiv-pi package root from this module's file URL.
40
- * Walks up from `extensions/rpiv-core/agents.ts` to the repo root.
41
- */
42
- export const PACKAGE_ROOT = (() => {
43
- const thisFile = fileURLToPath(import.meta.url);
44
- // extensions/rpiv-core/agents.ts -> rpiv-pi/
45
- return dirname(dirname(dirname(thisFile)));
46
- })();
47
-
48
- export const BUNDLED_AGENTS_DIR = join(PACKAGE_ROOT, "agents");
49
-
50
34
  // ---------------------------------------------------------------------------
51
35
  // Types
52
36
  // ---------------------------------------------------------------------------
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Resolved filesystem paths for rpiv-pi's own bundled resources.
3
+ *
4
+ * `PACKAGE_ROOT` is computed at module load from this file's URL. The walk-up
5
+ * is anchored to this file's location (`extensions/rpiv-core/paths.ts`) — three
6
+ * `dirname` levels reach the rpiv-pi package root. Other resource directories
7
+ * mirror the `pi.skills` / `pi.extensions` declarations in package.json.
8
+ *
9
+ * Pi's SDK does not expose a "give me my own extension root" API, so this is
10
+ * the idiomatic resolution path (see also docs/packages.md on `pi.*` manifest
11
+ * paths being relative to the package root).
12
+ */
13
+
14
+ import { dirname, join } from "node:path";
15
+ import { fileURLToPath } from "node:url";
16
+
17
+ export const PACKAGE_ROOT = (() => {
18
+ const thisFile = fileURLToPath(import.meta.url);
19
+ // extensions/rpiv-core/paths.ts -> rpiv-pi/
20
+ return dirname(dirname(dirname(thisFile)));
21
+ })();
22
+
23
+ export const BUNDLED_AGENTS_DIR = join(PACKAGE_ROOT, "agents");
24
+ export const BUNDLED_SKILLS_DIR = join(PACKAGE_ROOT, "skills");
@@ -56,10 +56,17 @@ afterEach(() => {
56
56
  });
57
57
 
58
58
  describe("registerSessionHooks — event wiring", () => {
59
- it("registers 5 events", () => {
59
+ it("registers 6 events", () => {
60
60
  const { pi, captured } = createMockPi();
61
61
  registerSessionHooks(pi);
62
- for (const ev of ["session_start", "session_compact", "session_shutdown", "tool_call", "before_agent_start"]) {
62
+ for (const ev of [
63
+ "session_start",
64
+ "session_compact",
65
+ "session_shutdown",
66
+ "tool_call",
67
+ "before_agent_start",
68
+ "agent_end",
69
+ ]) {
63
70
  expect(captured.events.has(ev)).toBe(true);
64
71
  }
65
72
  });
@@ -458,7 +465,7 @@ describe("before_agent_start hook", () => {
458
465
  registerSessionHooks(pi);
459
466
  const handler = captured.events.get("before_agent_start")?.[0];
460
467
  const ctx = createMockCtx({ cwd: projectDir });
461
- const r = await handler?.({} as never, ctx as never);
468
+ const r = await handler?.({ prompt: "" } as never, ctx as never);
462
469
  expect(r).toHaveProperty("message");
463
470
  });
464
471
 
@@ -469,8 +476,59 @@ describe("before_agent_start hook", () => {
469
476
  registerSessionHooks(pi);
470
477
  const handler = captured.events.get("before_agent_start")?.[0];
471
478
  const ctx = createMockCtx({ cwd: projectDir });
472
- await handler?.({} as never, ctx as never);
473
- const second = await handler?.({} as never, ctx as never);
479
+ await handler?.({ prompt: "" } as never, ctx as never);
480
+ const second = await handler?.({ prompt: "" } as never, ctx as never);
474
481
  expect(second).toBeUndefined();
475
482
  });
483
+
484
+ it("sets status to 'rpiv: <name>' when prompt contains an owned rpiv-pi skill block", async () => {
485
+ const { pi, captured } = createMockPi({
486
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
487
+ });
488
+ registerSessionHooks(pi);
489
+ const handler = captured.events.get("before_agent_start")?.[0];
490
+ const ctx = createMockCtx({ cwd: projectDir });
491
+ const skillPrompt = `<skill name="discover" location="/some/path">\nbody\n</skill>`;
492
+ await handler?.({ prompt: skillPrompt } as never, ctx as never);
493
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("rpiv-skill", "rpiv: discover");
494
+ });
495
+
496
+ it("does not set status for a skill block whose name is not bundled with rpiv-pi", async () => {
497
+ // Foreign / user-supplied skills must not be branded as rpiv: — only names that
498
+ // match a directory under packages/rpiv-pi/skills/ get the rpiv-skill status.
499
+ const { pi, captured } = createMockPi({
500
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
501
+ });
502
+ registerSessionHooks(pi);
503
+ const handler = captured.events.get("before_agent_start")?.[0];
504
+ const ctx = createMockCtx({ cwd: projectDir });
505
+ const skillPrompt = `<skill name="not-an-rpiv-skill" location="/home/u/.pi/skills/not-an-rpiv-skill">\nbody\n</skill>`;
506
+ await handler?.({ prompt: skillPrompt } as never, ctx as never);
507
+ const setStatusCalls = (ctx.ui.setStatus as ReturnType<typeof vi.fn>).mock.calls.filter(
508
+ (c) => c[0] === "rpiv-skill",
509
+ );
510
+ expect(setStatusCalls).toHaveLength(0);
511
+ });
512
+
513
+ it("does not set status when prompt has no skill block", async () => {
514
+ const { pi, captured } = createMockPi({
515
+ exec: stubGitExec({ branch: "main", commit: "abc", user: "alice" }) as never,
516
+ });
517
+ registerSessionHooks(pi);
518
+ const handler = captured.events.get("before_agent_start")?.[0];
519
+ const ctx = createMockCtx({ cwd: projectDir });
520
+ await handler?.({ prompt: "just a normal chat message" } as never, ctx as never);
521
+ expect(ctx.ui.setStatus).not.toHaveBeenCalled();
522
+ });
523
+ });
524
+
525
+ describe("agent_end hook", () => {
526
+ it("clears the rpiv-skill status", async () => {
527
+ const { pi, captured } = createMockPi();
528
+ registerSessionHooks(pi);
529
+ const handler = captured.events.get("agent_end")?.[0];
530
+ const ctx = createMockCtx();
531
+ await handler?.({ messages: [] } as never, ctx as never);
532
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith("rpiv-skill", undefined);
533
+ });
476
534
  });
@@ -8,9 +8,12 @@
8
8
  import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import {
11
+ type AgentEndEvent,
12
+ type BeforeAgentStartEvent,
11
13
  type ExtensionAPI,
12
14
  type ExtensionContext,
13
15
  isToolCallEventType,
16
+ parseSkillBlock,
14
17
  type ToolCallEvent,
15
18
  } from "@earendil-works/pi-coding-agent";
16
19
  import {
@@ -29,6 +32,7 @@ import {
29
32
  } from "./git-context.js";
30
33
  import { ARTIFACTS_SUBDIR, clearInjectionState, handleToolCallGuidance, injectRootGuidance } from "./guidance.js";
31
34
  import { findMissingSiblings } from "./package-checks.js";
35
+ import { BUNDLED_SKILLS_DIR } from "./paths.js";
32
36
 
33
37
  const msgAgentsAdded = (n: number) => `Copied ${n} rpiv-pi agent(s) to ~/.pi/agent/agents/`;
34
38
  const msgAgentsHealed = (parts: string[]) => `Synced bundled agent(s): ${parts.join(", ")}.`;
@@ -60,7 +64,8 @@ export function registerSessionHooks(pi: ExtensionAPI): void {
60
64
  pi.on("session_compact", async (_event, ctx) => onSessionCompact(_event, ctx, pi));
61
65
  pi.on("session_shutdown", async () => onSessionShutdown());
62
66
  pi.on("tool_call", async (event, ctx) => onToolCall(event, ctx, pi));
63
- pi.on("before_agent_start", async () => onBeforeAgentStart(pi));
67
+ pi.on("before_agent_start", async (event, ctx) => onBeforeAgentStart(event, ctx, pi));
68
+ pi.on("agent_end", async (_event, ctx) => onAgentEnd(_event, ctx));
64
69
  }
65
70
 
66
71
  // ---------------------------------------------------------------------------
@@ -107,17 +112,45 @@ async function onToolCall(event: ToolCallEvent, ctx: ExtensionContext, pi: Exten
107
112
  }
108
113
 
109
114
  async function onBeforeAgentStart(
115
+ event: BeforeAgentStartEvent,
116
+ ctx: ExtensionContext,
110
117
  pi: ExtensionAPI,
111
118
  ): Promise<{ message: ReturnType<typeof buildGitContextMessage> } | undefined> {
119
+ const parsed = parseSkillBlock(event.prompt);
120
+ if (parsed && isOwnedSkill(parsed.name)) ctx.ui.setStatus("rpiv-skill", `rpiv: ${parsed.name}`);
112
121
  const content = await takeGitContextIfChanged(pi);
113
122
  if (!content) return undefined;
114
123
  return { message: buildGitContextMessage(pi, content) };
115
124
  }
116
125
 
126
+ async function onAgentEnd(_event: AgentEndEvent, ctx: ExtensionContext): Promise<void> {
127
+ ctx.ui.setStatus("rpiv-skill", undefined);
128
+ }
129
+
117
130
  // ---------------------------------------------------------------------------
118
131
  // Helpers
119
132
  // ---------------------------------------------------------------------------
120
133
 
134
+ // Allowlist of rpiv-pi's own skill names, generated at module load by reading
135
+ // the package's bundled skills/ directory (see paths.ts — matches the
136
+ // `pi.skills` manifest in package.json). Prevents the status bar from
137
+ // claiming `rpiv:` ownership of user-supplied or third-party skills.
138
+ const OWNED_SKILL_NAMES: ReadonlySet<string> = (() => {
139
+ try {
140
+ return new Set(
141
+ readdirSync(BUNDLED_SKILLS_DIR, { withFileTypes: true })
142
+ .filter((e) => e.isDirectory())
143
+ .map((e) => e.name),
144
+ );
145
+ } catch {
146
+ return new Set<string>();
147
+ }
148
+ })();
149
+
150
+ function isOwnedSkill(name: string): boolean {
151
+ return OWNED_SKILL_NAMES.has(name);
152
+ }
153
+
121
154
  function resetInjectionState(): void {
122
155
  clearInjectionState();
123
156
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@juicesharp/rpiv-pi",
3
- "version": "1.9.1",
3
+ "version": "1.10.0",
4
4
  "description": "A skill-based development workflow for Pi Agent. Five skills (research, design, plan, implement, validate) and the shared subagents that compose its ship-loop.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -0,0 +1,42 @@
1
+ // Pre-bake the bail-out checks and reference values for the changelog skill.
2
+ //
3
+ // Prints:
4
+ // in_repo: yes|no
5
+ // last_tag: <tag>|(no tags)
6
+ // ---changelogs---
7
+ // <one CHANGELOG.md path per line> (empty if none tracked)
8
+ //
9
+ // All values are bounded. The unbounded `git log` and `git diff` calls
10
+ // remain LLM-issued via Bash (their output can exceed the 50KB tail budget).
11
+ //
12
+ // Always exits 0 — non-repo cwd collapses to `in_repo: no` and empty sections.
13
+ import { execFileSync } from "node:child_process";
14
+
15
+ const safe = (args, fb) => {
16
+ try {
17
+ return execFileSync("git", args, {
18
+ encoding: "utf-8",
19
+ stdio: ["ignore", "pipe", "ignore"],
20
+ });
21
+ } catch {
22
+ return fb;
23
+ }
24
+ };
25
+
26
+ const root = safe(["rev-parse", "--show-toplevel"], "").trim();
27
+ const inRepo = root ? "yes" : "no";
28
+
29
+ process.stdout.write(`in_repo: ${inRepo}\n`);
30
+
31
+ if (!root) {
32
+ process.stdout.write("last_tag: (not in a git repo)\n");
33
+ process.stdout.write("---changelogs---\n");
34
+ process.exit(0);
35
+ }
36
+
37
+ const lastTag = safe(["describe", "--tags", "--abbrev=0"], "").trim();
38
+ process.stdout.write(`last_tag: ${lastTag || "(no tags)"}\n`);
39
+
40
+ const changelogs = safe(["ls-files", "CHANGELOG.md", "**/CHANGELOG.md"], "");
41
+ process.stdout.write("---changelogs---\n");
42
+ process.stdout.write(changelogs);
@@ -0,0 +1,70 @@
1
+ // Pre-bake the "what's changed" snapshot for the commit skill.
2
+ //
3
+ // Prints:
4
+ // in_repo: yes|no
5
+ // ---status---
6
+ // <git status --short> (capped at 200 lines + footer)
7
+ // ---diffstat---
8
+ // <git diff HEAD --stat --ignore-submodules=all> | fallback for no-HEAD
9
+ //
10
+ // Full `git diff` is deliberately NOT included — large diffs would push the
11
+ // 50KB / 2000-line tail-truncation budget. The commit skill issues
12
+ // `git diff <file>` via the Bash tool when it needs per-file detail.
13
+ //
14
+ // Always exits 0 — non-repo cwd or no-HEAD initial repo collapses to safe
15
+ // fallback strings so the skill body never receives a `[Shell error: ...]`.
16
+ import { execFileSync } from "node:child_process";
17
+
18
+ const LINE_CAP = 200;
19
+
20
+ const safe = (args, fb) => {
21
+ try {
22
+ return execFileSync("git", args, {
23
+ encoding: "utf-8",
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ }).trim();
26
+ } catch {
27
+ return fb;
28
+ }
29
+ };
30
+
31
+ // Emit `raw` line-capped at LINE_CAP, with a truncation footer when over-limit.
32
+ // Empty input → `emptyLabel`. Both the status and diffstat sections are
33
+ // line-per-file in shape, so the "more files truncated" footer matches the
34
+ // convention in code-review/_helpers/review-range.mjs.
35
+ const emitCapped = (raw, emptyLabel) => {
36
+ const lines = raw.split("\n");
37
+ const trailingEmpty = lines.length > 0 && lines.at(-1) === "";
38
+ const real = trailingEmpty ? lines.slice(0, -1) : lines;
39
+ if (real.length === 0 || (real.length === 1 && real[0] === "")) {
40
+ process.stdout.write(`${emptyLabel}\n`);
41
+ } else if (real.length > LINE_CAP) {
42
+ process.stdout.write(real.slice(0, LINE_CAP).join("\n"));
43
+ process.stdout.write(`\n(... ${real.length - LINE_CAP} more files truncated ...)\n`);
44
+ } else {
45
+ process.stdout.write(`${real.join("\n")}\n`);
46
+ }
47
+ };
48
+
49
+ const root = safe(["rev-parse", "--show-toplevel"], "");
50
+ const inRepo = root ? "yes" : "no";
51
+
52
+ process.stdout.write(`in_repo: ${inRepo}\n`);
53
+
54
+ if (!root) {
55
+ process.exit(0);
56
+ }
57
+
58
+ process.stdout.write("---status---\n");
59
+ emitCapped(safe(["status", "--short"], ""), "(working tree clean)");
60
+
61
+ // `git diff HEAD --stat` errors on a fresh repo with no commits — substitute
62
+ // a fallback marker so the LLM knows the status block above already covers
63
+ // what would land in the initial commit.
64
+ const hasHead = safe(["rev-parse", "--verify", "--quiet", "HEAD"], "") !== "";
65
+ process.stdout.write("---diffstat---\n");
66
+ if (!hasHead) {
67
+ process.stdout.write("(no HEAD yet — initial commit; status above lists all files to be added)\n");
68
+ } else {
69
+ emitCapped(safe(["diff", "HEAD", "--stat", "--ignore-submodules=all"], ""), "(no changes against HEAD)");
70
+ }
@@ -0,0 +1,107 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ const GIT_CHANGES_MJS = fileURLToPath(new URL("./git-changes.mjs", import.meta.url));
9
+
10
+ const run = (cwd: string) =>
11
+ execFileSync("node", [GIT_CHANGES_MJS], {
12
+ cwd,
13
+ encoding: "utf-8",
14
+ stdio: ["ignore", "pipe", "ignore"],
15
+ });
16
+
17
+ const gitIn = (cwd: string, ...args: string[]) =>
18
+ execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
19
+
20
+ const initRepo = (cwd: string) => {
21
+ gitIn(cwd, "init", "--initial-branch=main", "-q");
22
+ gitIn(cwd, "config", "user.email", "test@example.com");
23
+ gitIn(cwd, "config", "user.name", "Test User");
24
+ gitIn(cwd, "config", "commit.gpgsign", "false");
25
+ };
26
+
27
+ let dir: string;
28
+
29
+ beforeEach(() => {
30
+ dir = mkdtempSync(join(tmpdir(), "rpiv-git-changes-"));
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(dir, { recursive: true, force: true });
35
+ });
36
+
37
+ describe("git-changes.mjs", () => {
38
+ it("emits `in_repo: no` and exits when cwd is not a git repo", () => {
39
+ const out = run(dir);
40
+ expect(out).toContain("in_repo: no");
41
+ expect(out).not.toContain("---status---");
42
+ expect(out).not.toContain("---diffstat---");
43
+ });
44
+
45
+ it("emits no-HEAD fallback for an initialized repo with zero commits", () => {
46
+ initRepo(dir);
47
+ writeFileSync(join(dir, "f.txt"), "hi");
48
+ gitIn(dir, "add", "f.txt");
49
+ const out = run(dir);
50
+ expect(out).toContain("in_repo: yes");
51
+ expect(out).toContain("---status---");
52
+ expect(out).toMatch(/^A\s+f\.txt$/m);
53
+ expect(out).toContain("---diffstat---");
54
+ expect(out).toContain("(no HEAD yet");
55
+ });
56
+
57
+ it("emits `(working tree clean)` when status is empty against HEAD", () => {
58
+ initRepo(dir);
59
+ writeFileSync(join(dir, "f.txt"), "hi");
60
+ gitIn(dir, "add", "f.txt");
61
+ gitIn(dir, "commit", "-m", "init", "-q");
62
+ const out = run(dir);
63
+ expect(out).toContain("---status---\n(working tree clean)");
64
+ expect(out).toContain("---diffstat---");
65
+ expect(out).toContain("(no changes against HEAD)");
66
+ });
67
+
68
+ it("emits diffstat lines when there are committed-tree differences", () => {
69
+ initRepo(dir);
70
+ writeFileSync(join(dir, "f.txt"), "hi");
71
+ gitIn(dir, "add", "f.txt");
72
+ gitIn(dir, "commit", "-m", "init", "-q");
73
+ writeFileSync(join(dir, "f.txt"), "hi\nworld\n");
74
+ const out = run(dir);
75
+ const diffstatBlock = out.slice(out.indexOf("---diffstat---"));
76
+ expect(diffstatBlock).toMatch(/f\.txt\s*\|\s*\d+/);
77
+ });
78
+
79
+ it("caps status at 200 files with `(... N more files truncated ...)` footer", () => {
80
+ initRepo(dir);
81
+ writeFileSync(join(dir, ".gitignore"), "");
82
+ gitIn(dir, "add", ".gitignore");
83
+ gitIn(dir, "commit", "-m", "init", "-q");
84
+ // Create 250 untracked files — `git status --short` lists each as `?? path`.
85
+ for (let i = 0; i < 250; i++) writeFileSync(join(dir, `f${i}.txt`), "");
86
+ const out = run(dir);
87
+ const statusBlock = out.slice(out.indexOf("---status---"), out.indexOf("---diffstat---"));
88
+ const statusLines = statusBlock.split("\n").filter((l) => l.startsWith("??"));
89
+ expect(statusLines).toHaveLength(200);
90
+ expect(statusBlock).toContain("(... 50 more files truncated ...)");
91
+ });
92
+
93
+ it("caps diffstat at 200 lines with truncation footer (I4 — symmetry with status cap)", () => {
94
+ initRepo(dir);
95
+ // Seed 250 files committed at HEAD, then modify all of them so
96
+ // `git diff HEAD --stat` produces 250+ lines (one per file + summary).
97
+ writeFileSync(join(dir, ".gitignore"), "");
98
+ gitIn(dir, "add", ".gitignore");
99
+ for (let i = 0; i < 250; i++) writeFileSync(join(dir, `f${i}.txt`), "a\n");
100
+ gitIn(dir, "add", "-A");
101
+ gitIn(dir, "commit", "-m", "seed", "-q");
102
+ for (let i = 0; i < 250; i++) writeFileSync(join(dir, `f${i}.txt`), "b\nc\n");
103
+ const out = run(dir);
104
+ const diffstatBlock = out.slice(out.indexOf("---diffstat---"));
105
+ expect(diffstatBlock).toContain("more files truncated ...)");
106
+ });
107
+ });
@@ -0,0 +1,37 @@
1
+ // Print six labeled lines summarising the current cwd's git state.
2
+ // Always exits 0 — every failure path collapses to a stable fallback so the
3
+ // skill body never receives a `[Shell error: ...]` substitution.
4
+ //
5
+ // branch: <name>|no-branch
6
+ // commit: <short-sha>|no-commit
7
+ // repo: <basename of toplevel>|unknown
8
+ // root: <absolute toplevel path>|(empty)
9
+ // in_repo: yes|no
10
+ // author: <git config user.name>|unknown
11
+ import { execFileSync } from "node:child_process";
12
+ import { basename } from "node:path";
13
+
14
+ const safe = (args, fb) => {
15
+ try {
16
+ const out = execFileSync("git", args, {
17
+ encoding: "utf-8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ return out || fb;
21
+ } catch {
22
+ return fb;
23
+ }
24
+ };
25
+
26
+ const root = safe(["rev-parse", "--show-toplevel"], "");
27
+ process.stdout.write(
28
+ [
29
+ `branch: ${safe(["branch", "--show-current"], "no-branch")}`,
30
+ `commit: ${safe(["rev-parse", "--short", "HEAD"], "no-commit")}`,
31
+ `repo: ${root ? basename(root) : "unknown"}`,
32
+ `root: ${root}`,
33
+ `in_repo: ${root ? "yes" : "no"}`,
34
+ `author: ${safe(["config", "user.name"], "unknown")}`,
35
+ "",
36
+ ].join("\n"),
37
+ );
@@ -0,0 +1,90 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { basename, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
7
+
8
+ const GIT_CONTEXT_MJS = fileURLToPath(new URL("./git-context.mjs", import.meta.url));
9
+
10
+ // Spawn git-context.mjs with `cwd` overridden so the helper resolves the
11
+ // passed tmpdir (not the test runner's repo root).
12
+ const runIn = (cwd: string) =>
13
+ execFileSync("node", [GIT_CONTEXT_MJS], {
14
+ cwd,
15
+ encoding: "utf-8",
16
+ stdio: ["ignore", "pipe", "ignore"],
17
+ });
18
+
19
+ const gitIn = (cwd: string, ...args: string[]) =>
20
+ execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"] });
21
+
22
+ const initRepo = (cwd: string) => {
23
+ gitIn(cwd, "init", "--initial-branch=main", "-q");
24
+ gitIn(cwd, "config", "user.email", "test@example.com");
25
+ gitIn(cwd, "config", "user.name", "Test User");
26
+ gitIn(cwd, "config", "commit.gpgsign", "false");
27
+ };
28
+
29
+ let dir: string;
30
+
31
+ beforeEach(() => {
32
+ dir = mkdtempSync(join(tmpdir(), "rpiv-git-context-"));
33
+ });
34
+
35
+ afterEach(() => {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("git-context.mjs", () => {
40
+ it("emits six labeled lines for a fresh repo with one commit", () => {
41
+ initRepo(dir);
42
+ writeFileSync(join(dir, "f.txt"), "hello");
43
+ gitIn(dir, "add", "f.txt");
44
+ gitIn(dir, "commit", "-m", "init", "-q");
45
+
46
+ const out = runIn(dir);
47
+ const lines = out.split("\n");
48
+ // macOS `mkdtempSync` returns `/var/folders/...` but `git rev-parse
49
+ // --show-toplevel` normalises symlinks to `/private/var/folders/...`.
50
+ // Compare against realpath so the test is platform-stable.
51
+ const realDir = realpathSync(dir);
52
+ expect(lines).toContain("branch: main");
53
+ expect(lines.some((l) => /^commit: [0-9a-f]{7,}$/.test(l))).toBe(true);
54
+ expect(lines).toContain(`repo: ${basename(realDir)}`);
55
+ expect(lines).toContain(`root: ${realDir}`);
56
+ expect(lines).toContain("in_repo: yes");
57
+ expect(lines).toContain("author: Test User");
58
+ });
59
+
60
+ it("falls back gracefully when cwd is not a git repo", () => {
61
+ // No `git init` — `git rev-parse --show-toplevel` fails.
62
+ const out = runIn(dir);
63
+ const lines = out.split("\n");
64
+ expect(lines).toContain("branch: no-branch");
65
+ expect(lines).toContain("commit: no-commit");
66
+ expect(lines).toContain("repo: unknown");
67
+ expect(lines).toContain("root: ");
68
+ expect(lines).toContain("in_repo: no");
69
+ // `author:` may be "unknown" or pulled from global git config — the
70
+ // helper falls back to `unknown` when `git config user.name` errors
71
+ // (which it doesn't when global config is present). Either is OK; the
72
+ // contract is just that the line is emitted.
73
+ expect(lines.some((l) => l.startsWith("author: "))).toBe(true);
74
+ });
75
+
76
+ it("exits 0 in both repo and non-repo cwds (never surfaces [Shell error])", () => {
77
+ expect(() => runIn(dir)).not.toThrow();
78
+ initRepo(dir);
79
+ expect(() => runIn(dir)).not.toThrow();
80
+ });
81
+
82
+ it("emits a trailing newline so chained helpers parse on their own line", () => {
83
+ initRepo(dir);
84
+ writeFileSync(join(dir, "f.txt"), "hello");
85
+ gitIn(dir, "add", "f.txt");
86
+ gitIn(dir, "commit", "-m", "init", "-q");
87
+ const out = runIn(dir);
88
+ expect(out.endsWith("\n")).toBe(true);
89
+ });
90
+ });
@@ -0,0 +1,35 @@
1
+ // List N most recently modified files in a directory, newest first, one per line.
2
+ // Usage: node list-recent.mjs <dir> [count=10]
3
+ // <dir> — relative paths are resolved against the git root (or cwd if not in a repo).
4
+ // <count> — max entries to print (default 10).
5
+ // Empty stdout when the directory does not exist or contains no files.
6
+ // Always exits 0 — directory-missing is a recoverable state, not an error.
7
+ import { execFileSync } from "node:child_process";
8
+ import { existsSync, readdirSync, statSync } from "node:fs";
9
+ import { isAbsolute, join, resolve } from "node:path";
10
+
11
+ const [rawDir = ".", nStr = "10"] = process.argv.slice(2);
12
+ const n = Math.max(1, Number.parseInt(nStr, 10) || 10);
13
+
14
+ const gitRoot = (() => {
15
+ try {
16
+ return execFileSync("git", ["rev-parse", "--show-toplevel"], {
17
+ encoding: "utf-8",
18
+ stdio: ["ignore", "pipe", "ignore"],
19
+ }).trim();
20
+ } catch {
21
+ return "";
22
+ }
23
+ })();
24
+
25
+ const dir = isAbsolute(rawDir) ? rawDir : resolve(gitRoot || process.cwd(), rawDir);
26
+
27
+ if (!existsSync(dir)) process.exit(0);
28
+
29
+ const items = readdirSync(dir, { withFileTypes: true })
30
+ .filter((d) => d.isFile())
31
+ .map((d) => ({ name: d.name, mtime: statSync(join(dir, d.name)).mtimeMs }))
32
+ .sort((a, b) => b.mtime - a.mtime)
33
+ .slice(0, n);
34
+
35
+ for (const it of items) console.log(it.name);