@ramarivera/coding-buddy 0.7.0 → 0.7.2
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/README.md +3 -2
- package/adapters/omp/renderers.ts +115 -19
- package/adapters/omp/ui.ts +2 -3
- package/cli/install.ts +41 -22
- package/cli/runtime-app.ts +49 -0
- package/hooks/buddy-comment.sh +10 -80
- package/hooks/file-type-react.sh +10 -402
- package/hooks/hooks.json +19 -5
- package/hooks/legacy/file-type-react.sh +403 -0
- package/hooks/legacy/mood-react.sh +210 -0
- package/hooks/legacy/name-react.sh +178 -0
- package/hooks/legacy/react.sh +1364 -0
- package/hooks/mood-react.sh +10 -209
- package/hooks/name-react.sh +10 -177
- package/hooks/react.sh +10 -1363
- package/hooks/suggest.sh +10 -54
- package/package.json +1 -1
- package/server/hooks/buddy-comment.ts +93 -0
- package/server/hooks/common.ts +95 -0
- package/server/hooks/legacy-bash.ts +34 -0
- package/server/hooks/suggest.ts +60 -0
- package/server/index.ts +21 -5
- package/server/path.ts +5 -0
- package/server/state.ts +15 -3
- package/statusline/buddy-status.sh +14 -6
- package/statusline/combined-status.sh +2 -2
- package/statusline/substatus.sh +66 -0
package/hooks/suggest.sh
CHANGED
|
@@ -1,55 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
LAST_SUGGESTION_FILE="$STATE_DIR/.last_suggestion"
|
|
13
|
-
SUGGESTIONS_FILE="$STATE_DIR/suggestions.json"
|
|
14
|
-
|
|
15
|
-
[ -f "$CONFIG_FILE" ] || exit 0
|
|
16
|
-
|
|
17
|
-
# Check if suggestions are enabled (default: true)
|
|
18
|
-
SUGGESTIONS_ENABLED="true"
|
|
19
|
-
if [ -f "$CONFIG_FILE" ]; then
|
|
20
|
-
_enabled=$(jq -r '.suggestionsEnabled // true' "$CONFIG_FILE" 2>/dev/null)
|
|
21
|
-
[ "$_enabled" = "false" ] && exit 0
|
|
22
|
-
fi
|
|
23
|
-
|
|
24
|
-
# Check cooldown (default: 180s = 3 min)
|
|
25
|
-
SUGGESTION_COOLDOWN=180
|
|
26
|
-
if [ -f "$CONFIG_FILE" ]; then
|
|
27
|
-
_cd=$(jq -r '.suggestionCooldown // 180' "$CONFIG_FILE" 2>/dev/null || echo 180)
|
|
28
|
-
[[ "$_cd" =~ ^[0-9]+$ ]] && SUGGESTION_COOLDOWN=$_cd
|
|
29
|
-
fi
|
|
30
|
-
|
|
31
|
-
if [ -f "$LAST_SUGGESTION_FILE" ]; then
|
|
32
|
-
LAST=$(cat "$LAST_SUGGESTION_FILE" 2>/dev/null)
|
|
33
|
-
NOW=$(date +%s)
|
|
34
|
-
DIFF=$(( NOW - ${LAST:-0} ))
|
|
35
|
-
[ "$DIFF" -lt "$SUGGESTION_COOLDOWN" ] && exit 0
|
|
36
|
-
fi
|
|
37
|
-
|
|
38
|
-
INPUT=$(cat)
|
|
39
|
-
|
|
40
|
-
# Extract messages
|
|
41
|
-
ASSISTANT_MSG=$(echo "$INPUT" | jq -r '.last_assistant_message // ""' 2>/dev/null)
|
|
42
|
-
[ -z "$ASSISTANT_MSG" ] && exit 0
|
|
43
|
-
|
|
44
|
-
# Record turn and check patterns
|
|
45
|
-
PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
46
|
-
|
|
47
|
-
# Check for patterns via the suggestions script
|
|
48
|
-
if [ -x "$(command -v bun)" ]; then
|
|
49
|
-
# Run the suggestion check (async, non-blocking)
|
|
50
|
-
bun run "$PLUGIN_ROOT/server/check-suggestions.ts" \
|
|
51
|
-
"$(echo "$ASSISTANT_MSG" | jq -Rs .)" \
|
|
52
|
-
>/dev/null 2>&1 &
|
|
53
|
-
fi
|
|
54
|
-
|
|
55
|
-
exit 0
|
|
2
|
+
set +e
|
|
3
|
+
SCRIPT_DIR="${BASH_SOURCE[0]%/*}"
|
|
4
|
+
ROOT="$(cd "$SCRIPT_DIR/.." 2>/dev/null && pwd)" || exit 0
|
|
5
|
+
[ -n "$ROOT" ] || exit 0
|
|
6
|
+
BUN="$(command -v bun 2>/dev/null || true)"
|
|
7
|
+
[ -x "$BUN" ] || BUN="$HOME/.bun/bin/bun"
|
|
8
|
+
[ -x "$BUN" ] || BUN="/opt/homebrew/bin/bun"
|
|
9
|
+
[ -x "$BUN" ] || BUN="/usr/local/bin/bun"
|
|
10
|
+
[ -x "$BUN" ] || exit 0
|
|
11
|
+
exec "$BUN" run "$ROOT/server/hooks/suggest.ts" || exit 0
|
package/package.json
CHANGED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import {
|
|
6
|
+
defaultSpawnDetached,
|
|
7
|
+
fileExists,
|
|
8
|
+
isOnCooldown,
|
|
9
|
+
nonNegativeInteger,
|
|
10
|
+
parseHookInput,
|
|
11
|
+
readJsonFile,
|
|
12
|
+
readStdin,
|
|
13
|
+
resolveHookSessionId,
|
|
14
|
+
resolveHookStateDir,
|
|
15
|
+
stringField,
|
|
16
|
+
type HookRuntime,
|
|
17
|
+
} from "./common.ts";
|
|
18
|
+
|
|
19
|
+
interface BuddyConfig {
|
|
20
|
+
commentCooldown?: unknown;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface Events {
|
|
24
|
+
turns?: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const BUDDY_COMMENT_PATTERN = /<!--\s*buddy:\s*([\s\S]*?)\s*-->/g;
|
|
29
|
+
|
|
30
|
+
export interface BuddyCommentResult {
|
|
31
|
+
comment?: string;
|
|
32
|
+
updated: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function extractBuddyComment(message: string): string {
|
|
36
|
+
let comment = "";
|
|
37
|
+
for (const match of message.matchAll(BUDDY_COMMENT_PATTERN)) {
|
|
38
|
+
const candidate = match[1]?.trim();
|
|
39
|
+
if (candidate) comment = candidate;
|
|
40
|
+
}
|
|
41
|
+
return comment;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function handleBuddyComment(rawInput: string, runtime: HookRuntime = {}): BuddyCommentResult {
|
|
45
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
46
|
+
if (!fileExists(join(stateDir, "status.json"))) return { updated: false };
|
|
47
|
+
|
|
48
|
+
const input = parseHookInput(rawInput);
|
|
49
|
+
if (!input) return { updated: false };
|
|
50
|
+
|
|
51
|
+
const assistantMessage = stringField(input, "last_assistant_message");
|
|
52
|
+
if (!assistantMessage) return { updated: false };
|
|
53
|
+
|
|
54
|
+
const comment = extractBuddyComment(assistantMessage);
|
|
55
|
+
if (!comment) return { updated: false };
|
|
56
|
+
|
|
57
|
+
const now = runtime.now?.() ?? Date.now();
|
|
58
|
+
const sid = resolveHookSessionId(runtime);
|
|
59
|
+
const config = readJsonFile<BuddyConfig>(join(stateDir, "config.json")) ?? {};
|
|
60
|
+
const cooldown = nonNegativeInteger(config.commentCooldown, 30);
|
|
61
|
+
const cooldownFile = join(stateDir, `.last_comment.${sid}`);
|
|
62
|
+
if (isOnCooldown(cooldownFile, cooldown, now)) return { updated: false };
|
|
63
|
+
|
|
64
|
+
mkdirSync(stateDir, { recursive: true });
|
|
65
|
+
writeFileSync(cooldownFile, String(Math.floor(now / 1000)));
|
|
66
|
+
writeFileSync(
|
|
67
|
+
join(stateDir, `reaction.${sid}.json`),
|
|
68
|
+
JSON.stringify({ reaction: comment, timestamp: now, reason: "turn" }),
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const eventsFile = join(stateDir, "events.json");
|
|
72
|
+
const events = readJsonFile<Events>(eventsFile) ?? {};
|
|
73
|
+
events.turns = (typeof events.turns === "number" ? events.turns : 0) + 1;
|
|
74
|
+
writeFileSync(eventsFile, JSON.stringify(events, null, 2));
|
|
75
|
+
|
|
76
|
+
const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
|
|
77
|
+
spawnDetached("server/award-xp.ts", ["turn"]);
|
|
78
|
+
spawnDetached("server/consolidate.ts", [
|
|
79
|
+
assistantMessage,
|
|
80
|
+
stringField(input, "last_user_message"),
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
return { comment, updated: true };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (import.meta.main) {
|
|
87
|
+
try {
|
|
88
|
+
handleBuddyComment(await readStdin());
|
|
89
|
+
} catch {
|
|
90
|
+
// Claude Code hooks must never fail the turn.
|
|
91
|
+
}
|
|
92
|
+
process.exit(0);
|
|
93
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { join, resolve } from "path";
|
|
4
|
+
import { buddyStateDir } from "../path.ts";
|
|
5
|
+
|
|
6
|
+
export type JsonObject = Record<string, unknown>;
|
|
7
|
+
|
|
8
|
+
export interface HookRuntime {
|
|
9
|
+
now?: () => number;
|
|
10
|
+
packageRoot?: string;
|
|
11
|
+
sessionId?: string;
|
|
12
|
+
spawnDetached?: (script: string, args: string[]) => void;
|
|
13
|
+
stateDir?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseHookInput(raw: string): JsonObject | null {
|
|
17
|
+
try {
|
|
18
|
+
const value = JSON.parse(raw) as unknown;
|
|
19
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
20
|
+
? value as JsonObject
|
|
21
|
+
: null;
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function stringField(input: JsonObject, field: string): string {
|
|
28
|
+
const value = input[field];
|
|
29
|
+
return typeof value === "string" ? value : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function resolveHookStateDir(runtime: HookRuntime = {}): string {
|
|
33
|
+
return runtime.stateDir ?? buddyStateDir();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function resolveHookSessionId(runtime: HookRuntime = {}): string {
|
|
37
|
+
if (runtime.sessionId) return runtime.sessionId;
|
|
38
|
+
const ccSid = process.env.CLAUDE_CODE_SESSION_ID;
|
|
39
|
+
if (ccSid) return ccSid.slice(0, 8);
|
|
40
|
+
const pane = process.env.TMUX_PANE;
|
|
41
|
+
if (pane) return pane.replace(/^%/, "");
|
|
42
|
+
return "default";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function readJsonFile<T>(path: string): T | null {
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf8")) as T;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function nonNegativeInteger(value: unknown, fallback: number): number {
|
|
54
|
+
if (Number.isInteger(value) && Number(value) >= 0) return Number(value);
|
|
55
|
+
if (typeof value === "string" && /^[0-9]+$/.test(value)) return Number(value);
|
|
56
|
+
return fallback;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isOnCooldown(path: string, cooldownSeconds: number, nowMs: number): boolean {
|
|
60
|
+
try {
|
|
61
|
+
const last = Number.parseInt(readFileSync(path, "utf8").trim(), 10);
|
|
62
|
+
if (!Number.isFinite(last)) return false;
|
|
63
|
+
return Math.floor(nowMs / 1000) - last < cooldownSeconds;
|
|
64
|
+
} catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function packageRootFromHooks(): string {
|
|
70
|
+
return resolve(import.meta.dir, "..", "..");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function defaultSpawnDetached(runtime: HookRuntime = {}): (script: string, args: string[]) => void {
|
|
74
|
+
return (script, args) => {
|
|
75
|
+
const root = runtime.packageRoot ?? packageRootFromHooks();
|
|
76
|
+
const child = spawn(process.execPath, ["run", join(root, script), ...args], {
|
|
77
|
+
cwd: root,
|
|
78
|
+
detached: true,
|
|
79
|
+
stdio: "ignore",
|
|
80
|
+
});
|
|
81
|
+
child.unref();
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fileExists(path: string): boolean {
|
|
86
|
+
try {
|
|
87
|
+
return existsSync(path);
|
|
88
|
+
} catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function readStdin(): Promise<string> {
|
|
94
|
+
return await Bun.stdin.text();
|
|
95
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { spawnSync } from "child_process";
|
|
4
|
+
import { existsSync } from "fs";
|
|
5
|
+
import { join } from "path";
|
|
6
|
+
import { packageRootFromHooks, readStdin } from "./common.ts";
|
|
7
|
+
|
|
8
|
+
const ALLOWED = new Set([
|
|
9
|
+
"react.sh",
|
|
10
|
+
"file-type-react.sh",
|
|
11
|
+
"name-react.sh",
|
|
12
|
+
"mood-react.sh",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
if (import.meta.main) {
|
|
16
|
+
try {
|
|
17
|
+
const script = process.argv[2] ?? "";
|
|
18
|
+
if (ALLOWED.has(script)) {
|
|
19
|
+
const root = packageRootFromHooks();
|
|
20
|
+
const hook = join(root, "hooks", "legacy", script);
|
|
21
|
+
if (existsSync(hook)) {
|
|
22
|
+
spawnSync("bash", [hook], {
|
|
23
|
+
cwd: root,
|
|
24
|
+
input: await readStdin(),
|
|
25
|
+
stdio: ["pipe", "ignore", "ignore"],
|
|
26
|
+
timeout: 14_000,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// Claude Code hooks must never fail the turn.
|
|
32
|
+
}
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import {
|
|
5
|
+
defaultSpawnDetached,
|
|
6
|
+
fileExists,
|
|
7
|
+
isOnCooldown,
|
|
8
|
+
nonNegativeInteger,
|
|
9
|
+
parseHookInput,
|
|
10
|
+
readJsonFile,
|
|
11
|
+
readStdin,
|
|
12
|
+
resolveHookStateDir,
|
|
13
|
+
stringField,
|
|
14
|
+
type HookRuntime,
|
|
15
|
+
} from "./common.ts";
|
|
16
|
+
|
|
17
|
+
interface SuggestConfig {
|
|
18
|
+
suggestionCooldown?: unknown;
|
|
19
|
+
suggestionsEnabled?: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SuggestHookResult {
|
|
23
|
+
checked: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function handleSuggest(rawInput: string, runtime: HookRuntime = {}): SuggestHookResult {
|
|
27
|
+
const stateDir = resolveHookStateDir(runtime);
|
|
28
|
+
const configFile = join(stateDir, "config.json");
|
|
29
|
+
if (!fileExists(configFile)) return { checked: false };
|
|
30
|
+
|
|
31
|
+
const config = readJsonFile<SuggestConfig>(configFile) ?? {};
|
|
32
|
+
if (config.suggestionsEnabled === false || config.suggestionsEnabled === "false") {
|
|
33
|
+
return { checked: false };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const now = runtime.now?.() ?? Date.now();
|
|
37
|
+
const cooldown = nonNegativeInteger(config.suggestionCooldown, 180);
|
|
38
|
+
if (isOnCooldown(join(stateDir, ".last_suggestion"), cooldown, now)) {
|
|
39
|
+
return { checked: false };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const input = parseHookInput(rawInput);
|
|
43
|
+
if (!input) return { checked: false };
|
|
44
|
+
|
|
45
|
+
const assistantMessage = stringField(input, "last_assistant_message");
|
|
46
|
+
if (!assistantMessage) return { checked: false };
|
|
47
|
+
|
|
48
|
+
const spawnDetached = runtime.spawnDetached ?? defaultSpawnDetached(runtime);
|
|
49
|
+
spawnDetached("server/check-suggestions.ts", [JSON.stringify(assistantMessage)]);
|
|
50
|
+
return { checked: true };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (import.meta.main) {
|
|
54
|
+
try {
|
|
55
|
+
handleSuggest(await readStdin());
|
|
56
|
+
} catch {
|
|
57
|
+
// Claude Code hooks must never fail the turn.
|
|
58
|
+
}
|
|
59
|
+
process.exit(0);
|
|
60
|
+
}
|
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"
|
|
@@ -31,6 +32,7 @@ NAME=$(jq -r '.name // ""' "$STATE" 2>/dev/null)
|
|
|
31
32
|
[ -z "$NAME" ] && exit 0
|
|
32
33
|
|
|
33
34
|
RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
|
|
35
|
+
STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
|
|
34
36
|
SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
|
|
35
37
|
REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
|
|
36
38
|
REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
|
|
@@ -38,7 +40,7 @@ ACHIEVEMENT=$(jq -r '.achievement // ""' "$STATE" 2>/dev/null)
|
|
|
38
40
|
LEVEL=$(jq -r '.level // 1' "$STATE" 2>/dev/null)
|
|
39
41
|
MOOD=$(jq -r '.mood // "focused"' "$STATE" 2>/dev/null)
|
|
40
42
|
|
|
41
|
-
cat
|
|
43
|
+
BUDDY_STATUSLINE_INPUT=$(cat)
|
|
42
44
|
|
|
43
45
|
# ─── Animation: pick current frame from server-rendered frames ──────────────
|
|
44
46
|
NOW=${BUDDY_FAKE_NOW:-$(date +%s)}
|
|
@@ -67,6 +69,7 @@ if [ -f "$CONFIG_FILE" ]; then
|
|
|
67
69
|
fi
|
|
68
70
|
|
|
69
71
|
NC=$'\033[0m'
|
|
72
|
+
NEUTRAL=$'\033[39m'
|
|
70
73
|
case "$RARITY" in
|
|
71
74
|
common)
|
|
72
75
|
[ "$_THEME" = "light" ] && C=$'\033[38;2;90;90;90m' || C=$'\033[38;2;153;153;153m' ;;
|
|
@@ -188,6 +191,7 @@ fi
|
|
|
188
191
|
# so the geometric center sits at col 6.
|
|
189
192
|
NAME_WITH_LEVEL="$NAME"
|
|
190
193
|
[ "$LEVEL" -gt 1 ] 2>/dev/null && NAME_WITH_LEVEL="${NAME} [L${LEVEL}]"
|
|
194
|
+
[ -n "$STARS" ] && NAME_WITH_LEVEL="${NAME_WITH_LEVEL} ${STARS}"
|
|
191
195
|
case "$MOOD" in
|
|
192
196
|
happy) MOOD_EMOJI="" ;;
|
|
193
197
|
focused) MOOD_EMOJI="" ;;
|
|
@@ -214,11 +218,11 @@ for line in "${ART_LINES[@]}"; do
|
|
|
214
218
|
if [ "$SHINY" = "true" ]; then
|
|
215
219
|
ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
|
|
216
220
|
else
|
|
217
|
-
ALL_COLORS+=("$
|
|
221
|
+
ALL_COLORS+=("$NEUTRAL")
|
|
218
222
|
fi
|
|
219
223
|
_arc=$(( _arc + 1 ))
|
|
220
224
|
done
|
|
221
|
-
ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$
|
|
225
|
+
ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
|
|
222
226
|
|
|
223
227
|
case "$SPECIES" in
|
|
224
228
|
axolotl|capybara|rabbit|mushroom|chonk) ART_W=10 ;;
|
|
@@ -377,18 +381,18 @@ for (( i=0; i<MAX_LINES; i++ )); do
|
|
|
377
381
|
|
|
378
382
|
# Connector: "-- " on the middle text line, spaces otherwise
|
|
379
383
|
if [ $bi -eq $CONNECTOR_BI ]; then
|
|
380
|
-
gap="${
|
|
384
|
+
gap="${NEUTRAL}--${NC} "
|
|
381
385
|
else
|
|
382
386
|
gap=" "
|
|
383
387
|
fi
|
|
384
388
|
|
|
385
389
|
if [ "$btype" = "border" ]; then
|
|
386
|
-
echo "${SPACER}${
|
|
390
|
+
echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
|
|
387
391
|
else
|
|
388
392
|
pipe_l="${bline:0:1}"
|
|
389
393
|
pipe_r="${bline: -1}"
|
|
390
394
|
inner="${bline:1:$(( ${#bline} - 2 ))}"
|
|
391
|
-
echo "${SPACER}${
|
|
395
|
+
echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
|
|
392
396
|
fi
|
|
393
397
|
else
|
|
394
398
|
empty=$(printf '%*s' "$BOX_W" '')
|
|
@@ -399,4 +403,8 @@ for (( i=0; i<MAX_LINES; i++ )); do
|
|
|
399
403
|
fi
|
|
400
404
|
done
|
|
401
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
|
+
|
|
402
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
|
+
}
|