@ramarivera/coding-buddy 0.7.1 → 0.7.3
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 +44 -75
- package/adapters/omp/ui.ts +2 -3
- package/adapters/pi/renderers.ts +34 -20
- package/adapters/pi/ui.ts +2 -3
- package/adapters/shared/widget-layout.ts +96 -0
- package/cli/install.ts +29 -16
- package/cli/runtime-app.ts +49 -0
- package/package.json +1 -1
- package/server/index.ts +21 -5
- package/server/path.ts +5 -0
- package/server/state.ts +15 -3
- package/statusline/buddy-status.sh +6 -1
- package/statusline/combined-status.sh +2 -2
- package/statusline/substatus.sh +66 -0
|
@@ -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.
|
|
8
|
+
"version": "0.7.3"
|
|
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.
|
|
15
|
+
"version": "0.7.3",
|
|
16
16
|
"author": {
|
|
17
17
|
"name": "ramarivera"
|
|
18
18
|
},
|
|
@@ -2,104 +2,73 @@ 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
|
+
displayWidth,
|
|
9
|
+
getDetailsWidth,
|
|
10
|
+
getWidgetWidth,
|
|
11
|
+
stripAnsi,
|
|
12
|
+
WIDGET_MAX_LINES,
|
|
13
|
+
wrapReaction,
|
|
14
|
+
} from "../shared/widget-layout.ts";
|
|
15
|
+
|
|
16
|
+
const RESET = "\x1b[0m";
|
|
17
|
+
const DIM_ITALIC = "\x1b[2;3m";
|
|
18
|
+
|
|
19
|
+
export { displayWidth, WIDGET_MAX_LINES as OMP_WIDGET_MAX_LINES };
|
|
5
20
|
|
|
6
21
|
function trimArt(line: string): string {
|
|
7
22
|
return line.replace(/\s+$/g, "");
|
|
8
23
|
}
|
|
9
24
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
for (const character of text) {
|
|
15
|
-
width += /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
|
|
16
|
-
}
|
|
17
|
-
return width;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function truncateToWidth(text: string, width: number): string {
|
|
21
|
-
let result = "";
|
|
22
|
-
let currentWidth = 0;
|
|
23
|
-
for (const character of text) {
|
|
24
|
-
const characterWidth = /\p{Emoji_Presentation}/u.test(character) ? 2 : 1;
|
|
25
|
-
if (currentWidth + characterWidth > width) break;
|
|
26
|
-
result += character;
|
|
27
|
-
currentWidth += characterWidth;
|
|
25
|
+
function getFullArtFrame(companion: Companion, frame: number): string[] {
|
|
26
|
+
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame);
|
|
27
|
+
if (companion.bones.hat !== "none" && !stripAnsi(art[0] ?? "").trim()) {
|
|
28
|
+
art[0] = HAT_ART[companion.bones.hat];
|
|
28
29
|
}
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function wrapReaction(reaction: string): string[] {
|
|
33
|
-
const lines: string[] = [];
|
|
34
|
-
let current = "";
|
|
35
|
-
|
|
36
|
-
for (const word of reaction.trim().split(/\s+/)) {
|
|
37
|
-
if (!word) continue;
|
|
38
|
-
if (displayWidth(word) > REACTION_WIDTH) {
|
|
39
|
-
if (current) lines.push(current);
|
|
40
|
-
lines.push(`${truncateToWidth(word, REACTION_WIDTH - 1)}…`);
|
|
41
|
-
current = "";
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const candidate = current ? `${current} ${word}` : word;
|
|
46
|
-
if (displayWidth(candidate) > REACTION_WIDTH) {
|
|
47
|
-
lines.push(current);
|
|
48
|
-
current = word;
|
|
49
|
-
} else {
|
|
50
|
-
current = candidate;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (current) lines.push(current);
|
|
55
|
-
return lines;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function renderReactionBubble(reaction: string): string[] {
|
|
59
|
-
const lines = wrapReaction(`💬 ${reaction}`);
|
|
60
|
-
const border = `╭${"─".repeat(REACTION_WIDTH + 2)}╮`;
|
|
61
|
-
return [
|
|
62
|
-
border,
|
|
63
|
-
...lines.map((line) => `│ ${line}${" ".repeat(Math.max(0, REACTION_WIDTH - displayWidth(line)))} │`),
|
|
64
|
-
`╰${"─".repeat(REACTION_WIDTH + 2)}╯`,
|
|
65
|
-
];
|
|
30
|
+
return art.map(trimArt);
|
|
66
31
|
}
|
|
67
32
|
|
|
68
|
-
|
|
69
|
-
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
70
|
-
const stars = RARITY_STARS[companion.bones.rarity];
|
|
71
|
-
return `${trimArt(getArtFrame(companion.bones.species, companion.bones.eye, Date.now())[0])} ${companion.name} ${stars}${shiny}`;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function renderBuddyWidget(
|
|
33
|
+
function renderDetails(
|
|
75
34
|
companion: Companion,
|
|
76
|
-
reaction
|
|
77
|
-
achievements: Achievement[]
|
|
35
|
+
reaction: ReactionState | null | undefined,
|
|
36
|
+
achievements: Achievement[],
|
|
37
|
+
sideWidth: number,
|
|
78
38
|
): string[] {
|
|
79
|
-
const
|
|
80
|
-
const art = getArtFrame(companion.bones.species, companion.bones.eye, frame).map(trimArt);
|
|
81
|
-
const hat = companion.bones.hat === "none" ? [] : [trimArt(HAT_ART[companion.bones.hat])];
|
|
39
|
+
const color = getRarityColor(companion.bones.rarity);
|
|
82
40
|
const stars = RARITY_STARS[companion.bones.rarity];
|
|
83
41
|
const shiny = companion.bones.shiny ? " ✨" : "";
|
|
84
42
|
const lines = [
|
|
85
|
-
|
|
86
|
-
`${companion.bones.rarity} ${companion.bones.species}
|
|
87
|
-
"",
|
|
88
|
-
...hat,
|
|
89
|
-
...art,
|
|
43
|
+
`${color}${companion.name} ${stars}${shiny}${RESET}`,
|
|
44
|
+
`${companion.bones.rarity} ${companion.bones.species}`,
|
|
90
45
|
];
|
|
91
46
|
|
|
92
47
|
if (reaction?.reaction) {
|
|
93
|
-
|
|
48
|
+
const remainingLines = Math.max(1, WIDGET_MAX_LINES - lines.length);
|
|
49
|
+
lines.push(...wrapReaction(reaction.reaction, sideWidth, remainingLines).map((line) => `${DIM_ITALIC}${line}${RESET}`));
|
|
94
50
|
}
|
|
95
51
|
|
|
96
|
-
|
|
97
|
-
lines.
|
|
52
|
+
for (const achievement of achievements) {
|
|
53
|
+
if (lines.length >= WIDGET_MAX_LINES) break;
|
|
54
|
+
lines.push(`🏆 ${achievement.name}`);
|
|
98
55
|
}
|
|
99
56
|
|
|
100
57
|
return lines;
|
|
101
58
|
}
|
|
102
59
|
|
|
60
|
+
export function renderBuddyWidget(
|
|
61
|
+
companion: Companion,
|
|
62
|
+
reaction?: ReactionState | null,
|
|
63
|
+
achievements: Achievement[] = [],
|
|
64
|
+
width: number = getWidgetWidth(),
|
|
65
|
+
): string[] {
|
|
66
|
+
const art = getFullArtFrame(companion, Math.floor(Date.now() / 700));
|
|
67
|
+
const sideWidth = getDetailsWidth(art, width);
|
|
68
|
+
const details = renderDetails(companion, reaction, achievements, sideWidth);
|
|
69
|
+
return composeDetailsAndArt(details, art, width, WIDGET_MAX_LINES);
|
|
70
|
+
}
|
|
71
|
+
|
|
103
72
|
export function renderBuddyStats(companion: Companion): string[] {
|
|
104
73
|
return [
|
|
105
74
|
`name: ${companion.name}`,
|
package/adapters/omp/ui.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { OmpBuddyUiContext } from "./context.ts";
|
|
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 { OmpBuddyStorage } from "./storage.ts";
|
|
6
6
|
|
|
7
7
|
export class OmpBuddyUI {
|
|
@@ -27,8 +27,7 @@ export class OmpBuddyUI {
|
|
|
27
27
|
): void {
|
|
28
28
|
const currentReaction = reaction ?? null;
|
|
29
29
|
const muted = this.storage.isMuted();
|
|
30
|
-
|
|
31
|
-
ctx.ui.setStatus("buddy", muted ? `${status} [muted]` : status);
|
|
30
|
+
ctx.ui.setStatus("buddy", undefined);
|
|
32
31
|
ctx.ui.setWidget(
|
|
33
32
|
"buddy",
|
|
34
33
|
renderBuddyWidget(companion, muted ? null : currentReaction, achievements),
|
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/cli/install.ts
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { readFileSync, writeFileSync, mkdirSync, existsSync, cpSync } from "fs";
|
|
11
|
-
import { execSync } from "child_process";
|
|
11
|
+
import { execFileSync, execSync } from "child_process";
|
|
12
12
|
import { resolve, dirname, join } from "path";
|
|
13
13
|
|
|
14
14
|
import { generateBones, renderBuddy, renderFace, RARITY_STARS } from "../core/engine.ts"
|
|
15
15
|
import {
|
|
16
16
|
claudeConfigDir,
|
|
17
|
+
buddyStateDir,
|
|
17
18
|
claudeSettingsPath,
|
|
18
19
|
claudeSkillDir,
|
|
19
20
|
claudeUserConfigPath,
|
|
@@ -21,6 +22,7 @@ import {
|
|
|
21
22
|
} from "../server/path.ts";
|
|
22
23
|
import { loadCompanion, saveCompanion, resolveUserId, writeStatusState } from "../server/state.ts";
|
|
23
24
|
import { generateFallbackName } from "../core/reactions.ts"
|
|
25
|
+
import { copyRuntimeApp, stableRuntimePaths } from "./runtime-app.ts";
|
|
24
26
|
|
|
25
27
|
const CYAN = "\x1b[36m";
|
|
26
28
|
const GREEN = "\x1b[32m";
|
|
@@ -116,8 +118,8 @@ function saveSettings(settings: Record<string, any>) {
|
|
|
116
118
|
|
|
117
119
|
// ─── Step 1: Register MCP server (in ~/.claude.json) ────────────────────────
|
|
118
120
|
|
|
119
|
-
function installMcp() {
|
|
120
|
-
const serverPath =
|
|
121
|
+
function installMcp(appDir: string) {
|
|
122
|
+
const serverPath = stableRuntimePaths(appDir).mcpServer;
|
|
121
123
|
|
|
122
124
|
let claudeJson: Record<string, any> = {};
|
|
123
125
|
try {
|
|
@@ -129,7 +131,7 @@ function installMcp() {
|
|
|
129
131
|
claudeJson.mcpServers["claude-buddy"] = {
|
|
130
132
|
command: "bun",
|
|
131
133
|
args: [toUnixPath(serverPath)],
|
|
132
|
-
cwd: toUnixPath(
|
|
134
|
+
cwd: toUnixPath(appDir),
|
|
133
135
|
};
|
|
134
136
|
|
|
135
137
|
writeFileSync(CLAUDE_JSON_PATH, JSON.stringify(claudeJson, null, 2));
|
|
@@ -147,8 +149,8 @@ function installSkill() {
|
|
|
147
149
|
|
|
148
150
|
// ─── Step 3: Configure status line (with animation refresh) ─────────────────
|
|
149
151
|
|
|
150
|
-
function installStatusLine(settings: Record<string, any
|
|
151
|
-
const statusScript =
|
|
152
|
+
function installStatusLine(settings: Record<string, any>, appDir: string) {
|
|
153
|
+
const statusScript = stableRuntimePaths(appDir).statusline;
|
|
152
154
|
|
|
153
155
|
settings.statusLine = {
|
|
154
156
|
type: "command",
|
|
@@ -181,13 +183,14 @@ function stripLegacyPopupHooks(settings: Record<string, any>) {
|
|
|
181
183
|
|
|
182
184
|
// ─── Step 4: Register hooks ─────────────────────────────────────────────────
|
|
183
185
|
|
|
184
|
-
function installHooks(settings: Record<string, any
|
|
185
|
-
const
|
|
186
|
-
const
|
|
187
|
-
const
|
|
188
|
-
const
|
|
189
|
-
const
|
|
190
|
-
const
|
|
186
|
+
function installHooks(settings: Record<string, any>, appDir: string) {
|
|
187
|
+
const hooksDir = stableRuntimePaths(appDir).hooks;
|
|
188
|
+
const reactHook = join(hooksDir, "react.sh");
|
|
189
|
+
const fileTypeHook = join(hooksDir, "file-type-react.sh");
|
|
190
|
+
const commentHook = join(hooksDir, "buddy-comment.sh");
|
|
191
|
+
const suggestHook = join(hooksDir, "suggest.sh");
|
|
192
|
+
const nameHook = join(hooksDir, "name-react.sh");
|
|
193
|
+
const moodHook = join(hooksDir, "mood-react.sh");
|
|
191
194
|
const commandHook = (command: string) => ({
|
|
192
195
|
type: "command",
|
|
193
196
|
command: toUnixPath(command),
|
|
@@ -239,6 +242,15 @@ function installHooks(settings: Record<string, any>) {
|
|
|
239
242
|
ok("Hooks registered: PostToolUse (Bash + Write/Edit) + Stop (comment + suggest) + UserPromptSubmit (name + mood)");
|
|
240
243
|
}
|
|
241
244
|
|
|
245
|
+
function installRuntimeApp(): string {
|
|
246
|
+
const appDir = copyRuntimeApp(PROJECT_ROOT, buddyStateDir());
|
|
247
|
+
const installArgs = ["install", "--production", "--ignore-scripts"];
|
|
248
|
+
if (existsSync(join(appDir, "bun.lock"))) installArgs.push("--frozen-lockfile");
|
|
249
|
+
execFileSync("bun", installArgs, { cwd: appDir, stdio: "ignore" });
|
|
250
|
+
ok(`Stable runtime installed: ${appDir}`);
|
|
251
|
+
return appDir;
|
|
252
|
+
}
|
|
253
|
+
|
|
242
254
|
// ─── Step 5: Ensure MCP tools are allowed ───────────────────────────────────
|
|
243
255
|
|
|
244
256
|
function ensurePermissions(settings: Record<string, any>) {
|
|
@@ -301,14 +313,15 @@ console.log("");
|
|
|
301
313
|
info("Installing coding-buddy...\n");
|
|
302
314
|
|
|
303
315
|
const settings = loadSettings();
|
|
316
|
+
const appDir = installRuntimeApp();
|
|
304
317
|
|
|
305
|
-
installMcp();
|
|
318
|
+
installMcp(appDir);
|
|
306
319
|
installSkill();
|
|
307
320
|
|
|
308
321
|
stripLegacyPopupHooks(settings);
|
|
309
|
-
installStatusLine(settings);
|
|
322
|
+
installStatusLine(settings, appDir);
|
|
310
323
|
|
|
311
|
-
installHooks(settings);
|
|
324
|
+
installHooks(settings, appDir);
|
|
312
325
|
ensurePermissions(settings);
|
|
313
326
|
saveSettings(settings);
|
|
314
327
|
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, rmSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
|
|
4
|
+
export const RUNTIME_DIRECTORIES = [
|
|
5
|
+
"core",
|
|
6
|
+
"server",
|
|
7
|
+
"hooks",
|
|
8
|
+
"statusline",
|
|
9
|
+
"scripts",
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export const RUNTIME_FILES = ["package.json", "bun.lock"] as const;
|
|
13
|
+
|
|
14
|
+
export function stableRuntimeAppDir(stateDir: string): string {
|
|
15
|
+
return join(stateDir, "app");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function stableRuntimePaths(appDir: string) {
|
|
19
|
+
return {
|
|
20
|
+
mcpServer: join(appDir, "server", "index.ts"),
|
|
21
|
+
mcpLauncher: join(appDir, "server", "mcp-launcher.sh"),
|
|
22
|
+
statusline: join(appDir, "statusline", "buddy-status.sh"),
|
|
23
|
+
combinedStatusline: join(appDir, "statusline", "combined-status.sh"),
|
|
24
|
+
hooks: join(appDir, "hooks"),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Replace the stable runtime copy. The app directory is owned by the
|
|
30
|
+
* installer, so removing it first also removes files deleted by an upgrade.
|
|
31
|
+
*/
|
|
32
|
+
export function copyRuntimeApp(sourceRoot: string, stateDir: string): string {
|
|
33
|
+
const appDir = stableRuntimeAppDir(stateDir);
|
|
34
|
+
rmSync(appDir, { recursive: true, force: true });
|
|
35
|
+
mkdirSync(appDir, { recursive: true });
|
|
36
|
+
|
|
37
|
+
for (const directory of RUNTIME_DIRECTORIES) {
|
|
38
|
+
const source = join(sourceRoot, directory);
|
|
39
|
+
if (!existsSync(source)) throw new Error(`Missing runtime directory: ${source}`);
|
|
40
|
+
cpSync(source, join(appDir, directory), { recursive: true, force: true });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
for (const file of RUNTIME_FILES) {
|
|
44
|
+
const source = join(sourceRoot, file);
|
|
45
|
+
if (existsSync(source)) cpSync(source, join(appDir, file), { force: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return appDir;
|
|
49
|
+
}
|
package/package.json
CHANGED
package/server/index.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
10
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
11
11
|
import { z } from "zod";
|
|
12
|
+
import { existsSync } from "fs";
|
|
12
13
|
import { join, resolve, dirname } from "path";
|
|
13
14
|
|
|
14
15
|
import { generateBones,
|
|
@@ -45,6 +46,7 @@ import {
|
|
|
45
46
|
} from "./state";
|
|
46
47
|
import {
|
|
47
48
|
buddyStateDir,
|
|
49
|
+
buddyAppDir,
|
|
48
50
|
claudeConfigDir,
|
|
49
51
|
claudeSettingsPath,
|
|
50
52
|
} from "./path";
|
|
@@ -622,7 +624,7 @@ server.tool("buddy_unmute", "Unmute buddy reactions", {}, async () => {
|
|
|
622
624
|
|
|
623
625
|
server.tool(
|
|
624
626
|
"buddy_statusline",
|
|
625
|
-
"Enable or disable the buddy status line,
|
|
627
|
+
"Enable or disable the buddy status line, toggle combined mode (shows rate-limit usage bars alongside the buddy), or set a cached asynchronous sub-status command. Returns current status if called without arguments.",
|
|
626
628
|
{
|
|
627
629
|
enabled: z
|
|
628
630
|
.boolean()
|
|
@@ -636,17 +638,24 @@ server.tool(
|
|
|
636
638
|
.describe(
|
|
637
639
|
"true to show rate-limit usage bars alongside buddy (requires python3), false for buddy-only mode.",
|
|
638
640
|
),
|
|
641
|
+
subStatusCommand: z
|
|
642
|
+
.string()
|
|
643
|
+
.optional()
|
|
644
|
+
.describe(
|
|
645
|
+
"Shell command whose cached output is appended below the buddy panel. Empty string clears it; the command always runs asynchronously.",
|
|
646
|
+
),
|
|
639
647
|
},
|
|
640
|
-
async ({ enabled, combined }) => {
|
|
641
|
-
if (enabled === undefined && combined === undefined) {
|
|
648
|
+
async ({ enabled, combined, subStatusCommand }) => {
|
|
649
|
+
if (enabled === undefined && combined === undefined && subStatusCommand === undefined) {
|
|
642
650
|
const cfg = loadConfig();
|
|
643
651
|
const state = cfg.statusLineEnabled ? "enabled" : "disabled";
|
|
644
652
|
const mode = cfg.useCombinedStatus ? "combined (with rate-limit bars)" : "basic (buddy only)";
|
|
653
|
+
const subStatus = cfg.subStatusCommand ?? "unset";
|
|
645
654
|
return {
|
|
646
655
|
content: [
|
|
647
656
|
{
|
|
648
657
|
type: "text",
|
|
649
|
-
text: `Status line: ${state}\nMode: ${mode}\nUse /buddy statusline on|off to toggle, /buddy statusline combined to add rate-limit bars.\nRestart Claude Code after changes for them to take effect.`,
|
|
658
|
+
text: `Status line: ${state}\nMode: ${mode}\nSub-status command: ${subStatus}\nUse /buddy statusline on|off to toggle, /buddy statusline combined to add rate-limit bars, or set subStatusCommand to append cached command output.\nRestart Claude Code after changes for them to take effect.`,
|
|
650
659
|
},
|
|
651
660
|
],
|
|
652
661
|
};
|
|
@@ -656,6 +665,10 @@ server.tool(
|
|
|
656
665
|
saveConfig({ useCombinedStatus: combined });
|
|
657
666
|
}
|
|
658
667
|
|
|
668
|
+
if (subStatusCommand !== undefined) {
|
|
669
|
+
saveConfig({ subStatusCommand: subStatusCommand.trim() ? subStatusCommand : undefined });
|
|
670
|
+
}
|
|
671
|
+
|
|
659
672
|
if (enabled !== undefined) {
|
|
660
673
|
saveConfig({ statusLineEnabled: enabled });
|
|
661
674
|
}
|
|
@@ -663,7 +676,10 @@ server.tool(
|
|
|
663
676
|
const cfg = loadConfig();
|
|
664
677
|
|
|
665
678
|
if (cfg.statusLineEnabled) {
|
|
666
|
-
const
|
|
679
|
+
const installedRoot = buddyAppDir();
|
|
680
|
+
const pluginRoot = existsSync(join(installedRoot, "statusline", "buddy-status.sh"))
|
|
681
|
+
? installedRoot
|
|
682
|
+
: resolve(dirname(import.meta.dir));
|
|
667
683
|
const scriptName = cfg.useCombinedStatus ? "combined-status.sh" : "buddy-status.sh";
|
|
668
684
|
const statusScript = join(pluginRoot, "statusline", scriptName);
|
|
669
685
|
setBuddyStatusLine(statusScript);
|
package/server/path.ts
CHANGED
|
@@ -80,3 +80,8 @@ export function buddyStateDir(): string {
|
|
|
80
80
|
if (cfgDir) return join(cfgDir, "buddy-state");
|
|
81
81
|
return join(homedir(), ".claude-buddy");
|
|
82
82
|
}
|
|
83
|
+
|
|
84
|
+
/** Stable per-profile runtime copy used by installer registrations. */
|
|
85
|
+
export function buddyAppDir(): string {
|
|
86
|
+
return join(buddyStateDir(), "app");
|
|
87
|
+
}
|
package/server/state.ts
CHANGED
|
@@ -324,6 +324,7 @@ export interface BuddyConfig {
|
|
|
324
324
|
bubbleWidth: number;
|
|
325
325
|
bubbleMargin: number;
|
|
326
326
|
useCombinedStatus: boolean;
|
|
327
|
+
subStatusCommand?: string;
|
|
327
328
|
rainbowColors?: string[];
|
|
328
329
|
theme: "dark" | "light" | "auto";
|
|
329
330
|
moodEnabled: boolean;
|
|
@@ -349,12 +350,22 @@ const DEFAULT_CONFIG: BuddyConfig = {
|
|
|
349
350
|
suggestionCooldown: 180,
|
|
350
351
|
};
|
|
351
352
|
|
|
353
|
+
export function normalizeConfig(data: unknown): BuddyConfig {
|
|
354
|
+
const values = data && typeof data === "object" && !Array.isArray(data)
|
|
355
|
+
? data
|
|
356
|
+
: {};
|
|
357
|
+
const config = { ...DEFAULT_CONFIG, ...values } as BuddyConfig;
|
|
358
|
+
if (typeof config.subStatusCommand !== "string" || config.subStatusCommand.trim() === "") {
|
|
359
|
+
delete config.subStatusCommand;
|
|
360
|
+
}
|
|
361
|
+
return config;
|
|
362
|
+
}
|
|
363
|
+
|
|
352
364
|
export function loadConfig(): BuddyConfig {
|
|
353
365
|
try {
|
|
354
|
-
|
|
355
|
-
return { ...DEFAULT_CONFIG, ...data };
|
|
366
|
+
return normalizeConfig(JSON.parse(readFileSync(CONFIG_FILE, "utf8")));
|
|
356
367
|
} catch {
|
|
357
|
-
return {
|
|
368
|
+
return normalizeConfig({});
|
|
358
369
|
}
|
|
359
370
|
}
|
|
360
371
|
|
|
@@ -507,6 +518,7 @@ const TRANSIENT_PREFIXES = [
|
|
|
507
518
|
"reaction.",
|
|
508
519
|
".last_reaction.",
|
|
509
520
|
".last_comment.",
|
|
521
|
+
".substatus.",
|
|
510
522
|
];
|
|
511
523
|
|
|
512
524
|
/**
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
# shellcheck source=../scripts/paths.sh
|
|
18
18
|
source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
|
|
19
|
+
source "$(dirname "${BASH_SOURCE[0]}")/substatus.sh"
|
|
19
20
|
|
|
20
21
|
STATE="$BUDDY_STATE_DIR/status.json"
|
|
21
22
|
CONFIG_FILE="$BUDDY_STATE_DIR/config.json"
|
|
@@ -39,7 +40,7 @@ ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
|
|
|
39
40
|
LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
|
|
40
41
|
MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
|
|
41
42
|
|
|
42
|
-
cat
|
|
43
|
+
BUDDY_STATUSLINE_INPUT=$(cat)
|
|
43
44
|
|
|
44
45
|
# ─── Animation: pick current frame from server-rendered frames ──────────────
|
|
45
46
|
NOW=${BUDDY_FAKE_NOW:-$(date +%s)}
|
|
@@ -402,4 +403,8 @@ for (( i=0; i<MAX_LINES; i++ )); do
|
|
|
402
403
|
fi
|
|
403
404
|
done
|
|
404
405
|
|
|
406
|
+
# Append the last cached sub-status result below the buddy panel and refresh
|
|
407
|
+
# it asynchronously when stale. The statusline itself never waits on it.
|
|
408
|
+
append_substatus
|
|
409
|
+
|
|
405
410
|
exit 0
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
# statusline/combined-status.sh
|
|
3
3
|
# Two-panel status line: rate-limit stats left, buddy art right.
|
|
4
|
-
# buddy-status.sh
|
|
4
|
+
# buddy-status.sh renders the buddy panel and shared cached sub-status below it.
|
|
5
5
|
|
|
6
6
|
[ "$BUDDY_SHELL" = "1" ] && exit 0
|
|
7
7
|
|
|
@@ -54,7 +54,7 @@ except Exception:
|
|
|
54
54
|
" 2>/dev/null)
|
|
55
55
|
|
|
56
56
|
# ── Capture buddy art (buddy reads state files, not stdin) ───────────────────
|
|
57
|
-
BUDDY_OUTPUT=$("$BUDDY_SCRIPT"
|
|
57
|
+
BUDDY_OUTPUT=$(printf '%s' "$STDIN_DATA" | "$BUDDY_SCRIPT" 2>/dev/null)
|
|
58
58
|
|
|
59
59
|
# No buddy output → exit silently (muted, no state, etc.)
|
|
60
60
|
[ -z "$BUDDY_OUTPUT" ] && exit 0
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared cached sub-status renderer for the buddy statusline scripts.
|
|
3
|
+
#
|
|
4
|
+
# The command is intentionally launched from a detached process. Claude Code
|
|
5
|
+
# kills a statusline process when the next one starts, so running the command
|
|
6
|
+
# inline would make slow commands disappear before they can finish.
|
|
7
|
+
|
|
8
|
+
_substatus_mtime() {
|
|
9
|
+
local path="$1"
|
|
10
|
+
local value
|
|
11
|
+
value=$(stat -f %m "$path" 2>/dev/null)
|
|
12
|
+
case "$value" in
|
|
13
|
+
''|*[!0-9]*) value=$(stat -c %Y "$path" 2>/dev/null) ;;
|
|
14
|
+
esac
|
|
15
|
+
case "$value" in
|
|
16
|
+
''|*[!0-9]*) echo 0 ;;
|
|
17
|
+
*) echo "$value" ;;
|
|
18
|
+
esac
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
append_substatus() {
|
|
22
|
+
local state_dir="$BUDDY_STATE_DIR"
|
|
23
|
+
local sid="${BUDDY_SID:-default}"
|
|
24
|
+
local config_file="$state_dir/config.json"
|
|
25
|
+
local cache_file="$state_dir/.substatus.$sid"
|
|
26
|
+
local lock_dir="$state_dir/.substatus.$sid.lock"
|
|
27
|
+
local command=""
|
|
28
|
+
local now cache_age lock_age
|
|
29
|
+
|
|
30
|
+
[ -f "$config_file" ] || return 0
|
|
31
|
+
command=$(jq -r '.subStatusCommand // ""' "$config_file" 2>/dev/null)
|
|
32
|
+
[ -n "$command" ] || return 0
|
|
33
|
+
|
|
34
|
+
# Always show the last complete result first. A missing or stale cache is
|
|
35
|
+
# allowed to be blank; the refresh below will populate the next tick.
|
|
36
|
+
[ -f "$cache_file" ] && cat "$cache_file"
|
|
37
|
+
|
|
38
|
+
now=$(date +%s)
|
|
39
|
+
cache_age=999999
|
|
40
|
+
if [ -f "$cache_file" ]; then
|
|
41
|
+
cache_age=$(( now - $(_substatus_mtime "$cache_file") ))
|
|
42
|
+
[ "$cache_age" -lt 0 ] && cache_age=0
|
|
43
|
+
fi
|
|
44
|
+
[ "$cache_age" -lt 5 ] && return 0
|
|
45
|
+
|
|
46
|
+
# mkdir is the portable atomic lock primitive. Only remove locks that are
|
|
47
|
+
# over a minute old, so a slow but healthy command is never duplicated.
|
|
48
|
+
if [ -d "$lock_dir" ]; then
|
|
49
|
+
lock_age=$(( now - $(_substatus_mtime "$lock_dir") ))
|
|
50
|
+
if [ "$lock_age" -gt 60 ]; then
|
|
51
|
+
rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null
|
|
52
|
+
fi
|
|
53
|
+
fi
|
|
54
|
+
|
|
55
|
+
mkdir "$lock_dir" 2>/dev/null || return 0
|
|
56
|
+
|
|
57
|
+
# Keep the parent statusline fast even when the command takes minutes.
|
|
58
|
+
# The explicit /dev/null stdin makes this process independent of Claude's
|
|
59
|
+
# killed statusline process; the captured payload is piped to the command.
|
|
60
|
+
(
|
|
61
|
+
local tmp_file="$cache_file.$$"
|
|
62
|
+
printf '%s' "${BUDDY_STATUSLINE_INPUT:-}" | sh -c "$command" > "$tmp_file" 2>/dev/null
|
|
63
|
+
mv "$tmp_file" "$cache_file" 2>/dev/null || rm -f "$tmp_file" 2>/dev/null
|
|
64
|
+
rmdir "$lock_dir" 2>/dev/null || rm -rf "$lock_dir" 2>/dev/null
|
|
65
|
+
) </dev/null > /dev/null 2>/dev/null &
|
|
66
|
+
}
|