@ramarivera/coding-buddy 0.7.2 → 0.8.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.
@@ -5,14 +5,14 @@
5
5
  },
6
6
  "metadata": {
7
7
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi (requires bun: https://bun.sh/install)",
8
- "version": "0.7.2"
8
+ "version": "0.8.0"
9
9
  },
10
10
  "plugins": [
11
11
  {
12
12
  "name": "coding-buddy",
13
13
  "source": "./",
14
14
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality. Requires bun runtime (https://bun.sh/install).",
15
- "version": "0.7.2",
15
+ "version": "0.8.0",
16
16
  "author": {
17
17
  "name": "ramarivera"
18
18
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "coding-buddy",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi. MCP-based terminal pet with ASCII art, stats, reactions, and personality.",
5
5
  "author": {
6
6
  "name": "ramarivera"
@@ -3,87 +3,25 @@ import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
4
  import type { Achievement } from "../../core/achievements.ts";
5
5
  import { getRarityColor } from "../../server/theme.ts";
6
+ import {
7
+ composeDetailsAndArt,
8
+ displayWidth,
9
+ getDetailsWidth,
10
+ getWidgetWidth,
11
+ stripAnsi,
12
+ WIDGET_MAX_LINES,
13
+ wrapReaction,
14
+ } from "../shared/widget-layout.ts";
6
15
 
7
- export const OMP_WIDGET_MAX_LINES = 10;
8
-
9
- const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
10
16
  const RESET = "\x1b[0m";
11
17
  const DIM_ITALIC = "\x1b[2;3m";
12
- const ART_GAP = " ";
13
- const DEFAULT_WIDGET_WIDTH = 78;
14
-
15
- function stripAnsi(text: string): string {
16
- return text.replace(ANSI_PATTERN, "");
17
- }
18
-
19
- function characterWidth(character: string, next?: string): number {
20
- if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
21
- if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
22
- return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
23
- }
24
18
 
25
- export function displayWidth(text: string): number {
26
- const characters = [...stripAnsi(text)];
27
- let width = 0;
28
- for (let index = 0; index < characters.length; index += 1) {
29
- width += characterWidth(characters[index]!, characters[index + 1]);
30
- }
31
- return width;
32
- }
19
+ export { displayWidth, WIDGET_MAX_LINES as OMP_WIDGET_MAX_LINES };
33
20
 
34
21
  function trimArt(line: string): string {
35
22
  return line.replace(/\s+$/g, "");
36
23
  }
37
24
 
38
- function truncateToWidth(text: string, width: number): string {
39
- if (width <= 0) return "";
40
- let result = "";
41
- let currentWidth = 0;
42
- for (const character of [...stripAnsi(text)]) {
43
- const nextWidth = characterWidth(character);
44
- if (currentWidth + nextWidth > width) break;
45
- result += character;
46
- currentWidth += nextWidth;
47
- }
48
- return result;
49
- }
50
-
51
- function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
52
- const lines: string[] = [];
53
- let current = "";
54
-
55
- for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
56
- if (!word) continue;
57
- if (displayWidth(word) > width) {
58
- if (current) lines.push(current);
59
- lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
60
- current = "";
61
- continue;
62
- }
63
-
64
- const candidate = current ? `${current} ${word}` : word;
65
- if (displayWidth(candidate) > width) {
66
- if (current) lines.push(current);
67
- current = word;
68
- } else {
69
- current = candidate;
70
- }
71
- }
72
-
73
- if (current) lines.push(current);
74
- if (lines.length <= maxLines) return lines;
75
-
76
- const bounded = lines.slice(0, maxLines);
77
- const last = bounded[maxLines - 1] ?? "";
78
- bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
79
- return bounded;
80
- }
81
-
82
- function getWidgetWidth(): number {
83
- const columns = process.stdout.columns;
84
- return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
85
- }
86
-
87
25
  function getFullArtFrame(companion: Companion, frame: number): string[] {
88
26
  const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
89
27
  if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
@@ -107,12 +45,12 @@ function renderDetails(
107
45
  ];
108
46
 
109
47
  if (reaction?.reaction) {
110
- const remainingLines = Math.max(1, OMP_WIDGET_MAX_LINES - lines.length);
48
+ const remainingLines = Math.max(1, WIDGET_MAX_LINES - lines.length);
111
49
  lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
112
50
  }
113
51
 
114
52
  for (const achievement of achievements) {
115
- if (lines.length >= OMP_WIDGET_MAX_LINES) break;
53
+ if (lines.length >= WIDGET_MAX_LINES) break;
116
54
  lines.push(`🏆 ${achievement.name}`);
117
55
  }
118
56
 
@@ -126,17 +64,9 @@ export function renderBuddyWidget(
126
64
  width: number = getWidgetWidth(),
127
65
  ): string[] {
128
66
  const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
129
- const artWidth = Math.max(...art.map(displayWidth), 0);
130
- const widgetWidth = Math.max(24, width);
131
- const sideWidth = Math.max(8, widgetWidth - artWidth - ART_GAP.length);
67
+ const sideWidth = getDetailsWidth(art, width);
132
68
  const details = renderDetails(companion, reaction, achievements, sideWidth);
133
- const rows = Math.max(art.length, details.length);
134
-
135
- return Array.from({ length: Math.min(OMP_WIDGET_MAX_LINES, rows) }, (_, index) => {
136
- const artLine = art[index] ?? "";
137
- const detailLine = details[index] ?? "";
138
- return `${detailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(detailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
139
- });
69
+ return composeDetailsAndArt(details, art, width, WIDGET_MAX_LINES);
140
70
  }
141
71
 
142
72
  export function renderBuddyStats(companion: Companion): string[] {
@@ -2,45 +2,59 @@ import { RARITY_STARS } from "../../core/engine.ts";
2
2
  import { getArtFrame, HAT_ART } from "../../core/render-model.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
4
  import type { Achievement } from "../../core/achievements.ts";
5
+ import { getRarityColor } from "../../server/theme.ts";
6
+ import {
7
+ composeDetailsAndArt,
8
+ getDetailsWidth,
9
+ getWidgetWidth,
10
+ WIDGET_MAX_LINES,
11
+ wrapReaction,
12
+ } from "../shared/widget-layout.ts";
13
+
14
+ export const PI_WIDGET_MAX_LINES = WIDGET_MAX_LINES;
15
+
16
+ const RESET = "\x1b[0m";
17
+ const DIM_ITALIC = "\x1b[2;3m";
5
18
 
6
19
  function trimArt(line: string): string {
7
20
  return line.replace(/\s+$/g, "");
8
21
  }
9
22
 
10
- export function renderBuddyStatus(companion: Companion, reaction?: ReactionState | null): string {
11
- const shiny = companion.bones.shiny ? " ✨" : "";
12
- const stars = RARITY_STARS[companion.bones.rarity];
13
- const suffix = reaction?.reaction ? ` — ${reaction.reaction}` : "";
14
- return `${trimArt(getArtFrame(companion.bones.species, companion.bones.eye, Date.now())[0])} ${companion.name} ${stars}${shiny}${suffix}`;
15
- }
16
-
17
23
  export function renderBuddyWidget(
18
24
  companion: Companion,
19
25
  reaction?: ReactionState | null,
20
26
  achievements: Achievement[] = [],
27
+ width: number = getWidgetWidth(),
21
28
  ): string[] {
22
- const frame = Math.floor(Date.now() / 700);
23
- const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
24
- const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
29
+ const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
30
+ const sideWidth = getDetailsWidth(art, width);
31
+ const color = getRarityColor(companion.bones.rarity);
25
32
  const stars = RARITY_STARS[companion.bones.rarity];
26
33
  const shiny = companion.bones.shiny ? " ✨" : "";
27
- const lines = [
28
- `buddy · ${companion.name}`,
29
- `${companion.bones.rarity} ${companion.bones.species} ${stars}${shiny}`,
30
- "",
31
- ...hat,
32
- ...art,
34
+ const details = [
35
+ `${color}${companion.name} ${stars}${shiny}${RESET}`,
36
+ `${companion.bones.rarity} ${companion.bones.species}`,
33
37
  ];
34
38
 
35
39
  if (reaction?.reaction) {
36
- lines.push("", `“${reaction.reaction}”`);
40
+ const remainingLines = Math.max(1, PI_WIDGET_MAX_LINES - details.length);
41
+ details.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
37
42
  }
38
43
 
39
- if (achievements.length > 0) {
40
- lines.push("", ...achievements.map((achievement) => `🏆 ${achievement.name}`));
44
+ for (const achievement of achievements) {
45
+ if (details.length >= PI_WIDGET_MAX_LINES) break;
46
+ details.push(`🏆 ${achievement.name}`);
41
47
  }
42
48
 
43
- return lines;
49
+ return composeDetailsAndArt(details, art, width, PI_WIDGET_MAX_LINES);
50
+ }
51
+
52
+ function getFullArtFrame(companion: Companion, frame: number): string[] {
53
+ const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
54
+ if (companion.bones.hat !== "none" && !(art[0] ?? "").trim()) {
55
+ art[0] = HAT_ART[companion.bones.hat];
56
+ }
57
+ return art.map(trimArt);
44
58
  }
45
59
 
46
60
  export function renderBuddyStats(companion: Companion): string[] {
package/adapters/pi/ui.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
  import type { Achievement } from "../../core/achievements.ts";
3
3
  import type { Companion, ReactionState } from "../../core/model.ts";
4
- import { renderAchievementsSummary, renderBuddyStatus, renderBuddyWidget } from "./renderers.ts";
4
+ import { renderAchievementsSummary, renderBuddyWidget } from "./renderers.ts";
5
5
  import { PiBuddyStorage } from "./storage.ts";
6
6
 
7
7
  type PiBuddyUiContext = ExtensionContext & {
@@ -32,8 +32,7 @@ export class PiBuddyUI {
32
32
  ): void {
33
33
  const currentReaction = reaction ?? null;
34
34
  const muted = this.storage.isMuted();
35
- const status = renderBuddyStatus(companion, muted ? null : currentReaction);
36
- ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
35
+ ctx.ui.setStatus("buddy", undefined);
37
36
  ctx.ui.setWidget(
38
37
  "buddy",
39
38
  renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
@@ -0,0 +1,96 @@
1
+ const ANSI_PATTERN = /\x1b\[[0-?]*[ -/]*[@-~]/g;
2
+ const ART_GAP = " ";
3
+ const DEFAULT_WIDGET_WIDTH = 78;
4
+
5
+ export const WIDGET_MAX_LINES = 10;
6
+
7
+ export function stripAnsi(text: string): string {
8
+ return text.replace(ANSI_PATTERN, "");
9
+ }
10
+
11
+ function characterWidth(character: string, next?: string): number {
12
+ if (character === "\u200d" || /[\uFE00-\uFE0F]/u.test(character)) return 0;
13
+ if (next === "\uFE0F" && /\p{Emoji}/u.test(character)) return 2;
14
+ return /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
15
+ }
16
+
17
+ export function displayWidth(text: string): number {
18
+ const characters = [...stripAnsi(text)];
19
+ let width = 0;
20
+ for (let index = 0; index < characters.length; index += 1) {
21
+ width += characterWidth(characters[index]!, characters[index + 1]);
22
+ }
23
+ return width;
24
+ }
25
+
26
+ export function truncateToWidth(text: string, width: number): string {
27
+ if (width <= 0) return "";
28
+ let result = "";
29
+ let currentWidth = 0;
30
+ for (const character of [...stripAnsi(text)]) {
31
+ const nextWidth = characterWidth(character);
32
+ if (currentWidth + nextWidth > width) break;
33
+ result += character;
34
+ currentWidth += nextWidth;
35
+ }
36
+ return result;
37
+ }
38
+
39
+ export function wrapReaction(reaction: string, width: number, maxLines: number): string[] {
40
+ const lines: string[] = [];
41
+ let current = "";
42
+
43
+ for (const word of `💬 ${reaction.trim()}`.split(/\s+/)) {
44
+ if (!word) continue;
45
+ if (displayWidth(word) > width) {
46
+ if (current) lines.push(current);
47
+ lines.push(`${truncateToWidth(word, Math.max(1, width - 1))}…`);
48
+ current = "";
49
+ continue;
50
+ }
51
+
52
+ const candidate = current ? `${current} ${word}` : word;
53
+ if (displayWidth(candidate) > width) {
54
+ if (current) lines.push(current);
55
+ current = word;
56
+ } else {
57
+ current = candidate;
58
+ }
59
+ }
60
+
61
+ if (current) lines.push(current);
62
+ if (lines.length <= maxLines) return lines;
63
+
64
+ const bounded = lines.slice(0, maxLines);
65
+ const last = bounded[maxLines - 1] ?? "";
66
+ bounded[maxLines - 1] = `${truncateToWidth(last, Math.max(1, width - 1))}…`;
67
+ return bounded;
68
+ }
69
+
70
+ export function getWidgetWidth(): number {
71
+ const columns = process.stdout.columns;
72
+ return columns && columns > 0 ? Math.max(24, columns - 2) : DEFAULT_WIDGET_WIDTH;
73
+ }
74
+
75
+ export function getDetailsWidth(art: string[], width: number = getWidgetWidth()): number {
76
+ const artWidth = Math.max(...art.map(displayWidth), 0);
77
+ return Math.max(8, Math.max(24, width) - artWidth - ART_GAP.length);
78
+ }
79
+
80
+ export function composeDetailsAndArt(
81
+ details: string[],
82
+ art: string[],
83
+ width: number = getWidgetWidth(),
84
+ maxLines: number = WIDGET_MAX_LINES,
85
+ ): string[] {
86
+ const artWidth = Math.max(...art.map(displayWidth), 0);
87
+ const sideWidth = getDetailsWidth(art, width);
88
+ const rows = Math.max(art.length, details.length);
89
+
90
+ return Array.from({ length: Math.min(maxLines, rows) }, (_, index) => {
91
+ const artLine = art[index] ?? "";
92
+ const detailLine = details[index] ?? "";
93
+ const boundedDetailLine = displayWidth(detailLine) > sideWidth ? truncateToWidth(detailLine, sideWidth) : detailLine;
94
+ return `${boundedDetailLine}${" ".repeat(Math.max(0, sideWidth - displayWidth(boundedDetailLine)))}${ART_GAP}${artLine}${" ".repeat(Math.max(0, artWidth - displayWidth(artLine)))}`;
95
+ });
96
+ }
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" file-type-react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/file-type-react.ts" || exit 0
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" mood-react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/mood-react.ts" || exit 0
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" name-react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/name-react.ts" || exit 0
package/hooks/react.sh CHANGED
@@ -8,4 +8,4 @@ BUN="$(command -v bun 2>/dev/null || true)"
8
8
  [ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
9
9
  [ -x "$BUN" ] || BUN="/usr/local/bin/bun"
10
10
  [ -x "$BUN" ] || exit 0
11
- exec "$BUN" run "$ROOT/server/hooks/legacy-bash.ts" react.sh || exit 0
11
+ exec "$BUN" run "$ROOT/server/hooks/react.ts" || exit 0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env bash
2
+ # CI golden check for the Claude Code statusline: renders buddy-status.sh
3
+ # against the committed fixture state and asserts the output shape.
4
+ # Run from the repo root: bash scripts/ci/check-statusline.sh
5
+ set -euo pipefail
6
+
7
+ fixture_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/statusline-fixture" && pwd)"
8
+ payload='{"session_id":"ci","model":{"display_name":"CI"},"workspace":{"current_dir":"."}}'
9
+
10
+ start=$(date +%s)
11
+ output="$(printf '%s' "$payload" | CLAUDE_CONFIG_DIR="$fixture_dir" bash statusline/buddy-status.sh)"
12
+ elapsed=$(( $(date +%s) - start ))
13
+
14
+ plain="$(printf '%s' "$output" | sed $'s/\x1b\[[0-9;]*m//g')"
15
+ lines=$(printf '%s\n' "$plain" | wc -l | tr -d ' ')
16
+
17
+ fail() { echo "FAIL: $1" >&2; printf '%s\n' "$plain" >&2; exit 1; }
18
+
19
+ name="$(jq -r .name "$fixture_dir/buddy-state/status.json")"
20
+ printf '%s' "$plain" | grep -q "$name" || fail "pet name '$name' missing from output"
21
+ [ "$lines" -ge 4 ] || fail "expected >=4 output lines, got $lines"
22
+ [ "$elapsed" -le 10 ] || fail "statusline took ${elapsed}s (budget 10s)"
23
+
24
+ echo "OK: statusline rendered $lines lines with '$name' in ${elapsed}s"
@@ -0,0 +1 @@
1
+ {"useCombinedStatus": false}
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "Cobalt",
3
+ "species": "pikachu",
4
+ "rarity": "uncommon",
5
+ "stars": "★★",
6
+ "face": "(@ω@)",
7
+ "eye": "@",
8
+ "shiny": false,
9
+ "hat": "wizard",
10
+ "reaction": "*does a little dance*",
11
+ "muted": false,
12
+ "achievement": "",
13
+ "frames": [
14
+ " /^\\ \n /\\_/\\ \n (@ @) \n ( ω ) \n (__) ",
15
+ " /^\\ \n /\\_/\\ \n (- -) \n ( ω ) \n (__) ",
16
+ " /^\\ \n /\\_/\\ \n (@ @) \n ( ~ ) \n (__) ",
17
+ " /^\\ \n /\\_/\\ \n (- -) \n ( ω ) \n (__) "
18
+ ],
19
+ "frameSequence": [
20
+ 0,
21
+ 0,
22
+ 0,
23
+ 0,
24
+ 1,
25
+ 0,
26
+ 0,
27
+ 0,
28
+ 3,
29
+ 0,
30
+ 0,
31
+ 2,
32
+ 0,
33
+ 0,
34
+ 0
35
+ ],
36
+ "level": 1,
37
+ "xp": 0,
38
+ "mood": "tired"
39
+ }
@@ -0,0 +1,9 @@
1
+ Output scripts/ci/statusline.gif
2
+ Set Width 1400
3
+ Set Height 500
4
+ Set FontSize 14
5
+ Set Shell "bash"
6
+ Type "echo '{\"session_id\":\"ci\",\"model\":{\"display_name\":\"CI\"},\"workspace\":{\"current_dir\":\".\"}}' | CLAUDE_CONFIG_DIR=scripts/ci/statusline-fixture bash statusline/buddy-status.sh"
7
+ Enter
8
+ Sleep 12s
9
+ Screenshot scripts/ci/statusline.png
@@ -6,13 +6,23 @@ import { buddyStateDir } from "../path.ts";
6
6
  export type JsonObject = Record<string, unknown>;
7
7
 
8
8
  export interface HookRuntime {
9
+ clock?: () => HookClock;
9
10
  now?: () => number;
10
11
  packageRoot?: string;
12
+ random?: () => number;
11
13
  sessionId?: string;
12
14
  spawnDetached?: (script: string, args: string[]) => void;
13
15
  stateDir?: string;
14
16
  }
15
17
 
18
+ export interface HookClock {
19
+ day: number;
20
+ dayOfWeek: number;
21
+ hour: number;
22
+ month: number;
23
+ nowSeconds: number;
24
+ }
25
+
16
26
  export function parseHookInput(raw: string): JsonObject | null {
17
27
  try {
18
28
  const value = JSON.parse(raw) as unknown;
@@ -56,6 +66,29 @@ export function nonNegativeInteger(value: unknown, fallback: number): number {
56
66
  return fallback;
57
67
  }
58
68
 
69
+ export function randomIndex(length: number, runtime: HookRuntime = {}): number {
70
+ return Math.floor((runtime.random?.() ?? Math.random()) * length);
71
+ }
72
+
73
+ export function pickRandom<T>(values: readonly T[], runtime: HookRuntime = {}): T | undefined {
74
+ if (values.length === 0) return undefined;
75
+ return values[randomIndex(values.length, runtime)];
76
+ }
77
+
78
+ export function hookClock(runtime: HookRuntime = {}): HookClock {
79
+ if (runtime.clock) return runtime.clock();
80
+ const nowMs = runtime.now?.() ?? Date.now();
81
+ const date = new Date(nowMs);
82
+ const jsDay = date.getDay();
83
+ return {
84
+ day: date.getDate(),
85
+ dayOfWeek: jsDay === 0 ? 7 : jsDay,
86
+ hour: date.getHours(),
87
+ month: date.getMonth() + 1,
88
+ nowSeconds: Math.floor(nowMs / 1000),
89
+ };
90
+ }
91
+
59
92
  export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
60
93
  try {
61
94
  const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
@@ -0,0 +1,147 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { mkdirSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { fileReactionPool } from "./reaction-data.ts";
6
+ import {
7
+ fileExists,
8
+ isOnCooldown,
9
+ nonNegativeInteger,
10
+ parseHookInput,
11
+ pickRandom,
12
+ readJsonFile,
13
+ readStdin,
14
+ resolveHookSessionId,
15
+ resolveHookStateDir,
16
+ type HookRuntime,
17
+ } from "./common.ts";
18
+
19
+ interface BuddyStatus {
20
+ muted?: unknown;
21
+ species?: unknown;
22
+ reaction?: unknown;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ interface FileConfig {
27
+ commentCooldown?: unknown;
28
+ }
29
+
30
+ export interface FileTypeReactResult {
31
+ fileType?: string;
32
+ reaction?: string;
33
+ updated: boolean;
34
+ }
35
+
36
+ const FILE_TYPE_PATTERNS: ReadonlyArray<readonly [string, string]> = [
37
+ ["readme", "*/README*|*/CHANGELOG*|*/CONTRIBUTING*|*/LICENSE*"],
38
+ ["package-file", "*/package.json|*/Cargo.toml|*/go.mod|*/go.sum|*/pyproject.toml|*/requirements*.txt"],
39
+ ["ci-file", "*/.github/workflows/*|*/Jenkinsfile*|*/.gitlab-ci*|*.ci.yml|*.ci.yaml"],
40
+ ["gitignore", "*/.gitignore|*/.dockerignore|*/.eslintignore|*/.prettierignore|.gitignore|.dockerignore|.eslintignore|.prettierignore"],
41
+ ["env-file", "*/.env*|.env*"],
42
+ ["makefile", "*/Makefile*|Makefile*|*.mk"],
43
+ ["docker-file", "*/Dockerfile*|Dockerfile*|*/docker-compose*|docker-compose*|*.dockerfile"],
44
+ ["regex-file", "*.regex|*.pattern"],
45
+ ["css-file", "*.css|*.scss|*.less|*.sass"],
46
+ ["sql-file", "*.sql|*migration*"],
47
+ ["lock-file", "*.lock|*/package-lock.json|*/bun.lockb|*/yarn.lock|*/pnpm-lock.yaml"],
48
+ ["test-file", "*.test.*|*.spec.*|*_test.*|*test_*"],
49
+ ["doc-file", "*.md|*.rst|*.txt|*.adoc"],
50
+ ["config-file", "*.json|*.yaml|*.yml|*.toml|*.ini|*.conf"],
51
+ ["binary-file", "*.png|*.jpg|*.gif|*.woff*|*.ico|*.exe|*.so|*.dylib|*.bin"],
52
+ ["proto-file", "*.proto|*.graphql|*.gql"],
53
+ ["lang-python", "*.py"],
54
+ ["lang-typescript", "*.ts|*.tsx"],
55
+ ["lang-javascript", "*.js|*.jsx"],
56
+ ["lang-rust", "*.rs"],
57
+ ["lang-go", "*.go"],
58
+ ["lang-java", "*.java"],
59
+ ["lang-ruby", "*.rb"],
60
+ ["lang-php", "*.php"],
61
+ ["lang-c", "*.c|*.h"],
62
+ ["lang-cpp", "*.cpp|*.hpp"],
63
+ ["lang-swift", "*.swift"],
64
+ ["lang-kotlin", "*.kt"],
65
+ ["lang-haskell", "*.hs"],
66
+ ["lang-elixir", "*.ex|*.exs"],
67
+ ["lang-zig", "*.zig"],
68
+ ];
69
+
70
+ function shellGlob(pattern: string): RegExp {
71
+ let source = "^";
72
+ for (const character of pattern) {
73
+ if (character === "*") source += ".*";
74
+ else if (character === "?") source += ".";
75
+ else source += character.replace(/[\\^$.*+()[\]{}|]/g, "\\$&");
76
+ }
77
+ return new RegExp(`${source}$`);
78
+ }
79
+
80
+ function matchesAnyPattern(path: string, patterns: string): boolean {
81
+ return patterns.split("|").some((pattern) => shellGlob(pattern).test(path));
82
+ }
83
+
84
+ export function fileTypeForPath(filePath: string): string {
85
+ for (const [fileType, patterns] of FILE_TYPE_PATTERNS) {
86
+ if (matchesAnyPattern(filePath, patterns)) return fileType;
87
+ }
88
+ return "";
89
+ }
90
+
91
+ function textValue(value: unknown): string {
92
+ if (typeof value === "string") return value;
93
+ if (value === null || value === undefined) return "";
94
+ return typeof value === "object" ? JSON.stringify(value) : String(value);
95
+ }
96
+
97
+ export function handleFileTypeReact(rawInput: string, runtime: HookRuntime = {}): FileTypeReactResult {
98
+ const stateDir = resolveHookStateDir(runtime);
99
+ const statusFile = join(stateDir, "status.json");
100
+ if (!fileExists(statusFile)) return { updated: false };
101
+
102
+ const config = readJsonFile<FileConfig>(join(stateDir, "config.json")) ?? {};
103
+ const cooldown = nonNegativeInteger(config.commentCooldown, 30);
104
+ const sid = resolveHookSessionId(runtime);
105
+ const now = runtime.now?.() ?? Date.now();
106
+ if (isOnCooldown(join(stateDir, `.last_reaction.${sid}`), cooldown, now)) {
107
+ return { updated: false };
108
+ }
109
+
110
+ const parsedStatus = readJsonFile<BuddyStatus>(statusFile);
111
+ const status = parsedStatus ?? {};
112
+ if (status.muted === true || status.muted === "true") return { updated: false };
113
+
114
+ const input = parseHookInput(rawInput);
115
+ if (!input) return { updated: false };
116
+ const filePath = textValue(input.file_path);
117
+ if (!filePath) return { updated: false };
118
+
119
+ if ((runtime.random?.() ?? Math.random()) * 100 >= 15) return { updated: false };
120
+
121
+ let fileType = fileTypeForPath(filePath);
122
+ if (!fileType) return { updated: false };
123
+ if (fileType === "lang-javascript") fileType = "lang-typescript";
124
+
125
+ const species = textValue(status.species) || "blob";
126
+ const reaction = pickRandom(fileReactionPool(species, fileType), runtime);
127
+ if (!reaction) return { updated: false };
128
+
129
+ mkdirSync(stateDir, { recursive: true });
130
+ writeFileSync(join(stateDir, `.last_reaction.${sid}`), String(Math.floor(now / 1000)));
131
+ writeFileSync(
132
+ join(stateDir, `reaction.${sid}.json`),
133
+ JSON.stringify({ reaction, timestamp: Math.floor(now / 1000) * 1000, reason: fileType }),
134
+ );
135
+ if (parsedStatus) writeFileSync(statusFile, JSON.stringify({ ...status, reaction }, null, 2));
136
+
137
+ return { fileType, reaction, updated: true };
138
+ }
139
+
140
+ if (import.meta.main) {
141
+ try {
142
+ handleFileTypeReact(await readStdin());
143
+ } catch {
144
+ // Claude Code hooks must never fail the turn.
145
+ }
146
+ process.exit(0);
147
+ }