@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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/adapters/omp/renderers.ts +14 -84
- package/adapters/pi/renderers.ts +34 -20
- package/adapters/pi/ui.ts +2 -3
- package/adapters/shared/widget-layout.ts +96 -0
- package/hooks/file-type-react.sh +1 -1
- package/hooks/mood-react.sh +1 -1
- package/hooks/name-react.sh +1 -1
- package/hooks/react.sh +1 -1
- package/package.json +1 -1
- package/scripts/ci/check-statusline.sh +24 -0
- package/scripts/ci/statusline-fixture/buddy-state/config.json +1 -0
- package/scripts/ci/statusline-fixture/buddy-state/status.json +39 -0
- package/scripts/ci/statusline.tape +9 -0
- package/server/hooks/common.ts +33 -0
- package/server/hooks/file-type-react.ts +147 -0
- package/server/hooks/mood-react.ts +107 -0
- package/server/hooks/name-react.ts +98 -0
- package/server/hooks/react.ts +413 -0
- package/server/hooks/reaction-data.ts +2715 -0
- package/server/state.ts +5 -0
- package/statusline/substatus.sh +27 -6
- package/hooks/legacy/file-type-react.sh +0 -403
- package/hooks/legacy/mood-react.sh +0 -210
- package/hooks/legacy/name-react.sh +0 -178
- package/hooks/legacy/react.sh +0 -1364
- package/server/hooks/legacy-bash.ts +0 -34
|
@@ -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.
|
|
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.
|
|
15
|
+
"version": "0.8.0",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "ramarivera"
|
|
18
18
|
},
|
|
@@ -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
|
|
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,
|
|
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 >=
|
|
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
|
|
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
|
-
|
|
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[] {
|
package/adapters/pi/renderers.ts
CHANGED
|
@@ -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
|
|
23
|
-
const
|
|
24
|
-
const
|
|
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
|
|
28
|
-
|
|
29
|
-
`${companion.bones.rarity} ${companion.bones.species}
|
|
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
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|
package/hooks/file-type-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/
|
|
11
|
+
exec "$BUN" run "$ROOT/server/hooks/file-type-react.ts" || exit 0
|
package/hooks/mood-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/
|
|
11
|
+
exec "$BUN" run "$ROOT/server/hooks/mood-react.ts" || exit 0
|
package/hooks/name-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/
|
|
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/
|
|
11
|
+
exec "$BUN" run "$ROOT/server/hooks/react.ts" || exit 0
|
package/package.json
CHANGED
|
@@ -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
|
package/server/hooks/common.ts
CHANGED
|
@@ -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
|
+
}
|