@ramarivera/coding-buddy 0.7.0 → 0.7.1

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/hooks/suggest.sh CHANGED
@@ -1,55 +1,11 @@
1
1
  #!/usr/bin/env bash
2
- # coding-buddy Stop hook — proactive suggestion detection
3
- # Detects coding patterns and offers suggestions when relevant.
4
- #
5
- # Combined with buddy-comment.sh to avoid running two separate Stop hooks.
6
-
7
- # shellcheck source=../scripts/paths.sh
8
- source "$(dirname "${BASH_SOURCE[0]}")/../scripts/paths.sh"
9
-
10
- STATE_DIR="$BUDDY_STATE_DIR"
11
- CONFIG_FILE="$STATE_DIR/config.json"
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-buddy",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "description": "Persistent coding companion for Claude Code, Pi, and Oh My Pi",
5
5
  "type": "module",
6
6
  "omp": {
@@ -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
+ }
@@ -31,6 +31,7 @@ NAME=$(jq -r '.name // ""' "$STATE" 2>/dev/null)
31
31
  [ -z "$NAME" ] && exit 0
32
32
 
33
33
  RARITY=$(jq -r '.rarity // "common"' "$STATE" 2>/dev/null)
34
+ STARS=$(jq -r '.stars // ""' "$STATE" 2>/dev/null)
34
35
  SHINY=$(jq -r '.shiny // false' "$STATE" 2>/dev/null)
35
36
  REACTION_FILE="$BUDDY_STATE_DIR/reaction.$SID.json"
36
37
  REACTION=$(jq -r '.reaction // ""' "$REACTION_FILE" 2>/dev/null)
@@ -67,6 +68,7 @@ if [ -f "$CONFIG_FILE" ]; then
67
68
  fi
68
69
 
69
70
  NC=$'\033[0m'
71
+ NEUTRAL=$'\033[39m'
70
72
  case "$RARITY" in
71
73
  common)
72
74
  [ "$_THEME" = "light" ] && C=$'\033[38;2;90;90;90m' || C=$'\033[38;2;153;153;153m' ;;
@@ -188,6 +190,7 @@ fi
188
190
  # so the geometric center sits at col 6.
189
191
  NAME_WITH_LEVEL="$NAME"
190
192
  [ "$LEVEL" -gt 1 ] 2>/dev/null && NAME_WITH_LEVEL="${NAME} [L${LEVEL}]"
193
+ [ -n "$STARS" ] && NAME_WITH_LEVEL="${NAME_WITH_LEVEL} ${STARS}"
191
194
  case "$MOOD" in
192
195
  happy) MOOD_EMOJI="" ;;
193
196
  focused) MOOD_EMOJI="" ;;
@@ -214,11 +217,11 @@ for line in "${ART_LINES[@]}"; do
214
217
  if [ "$SHINY" = "true" ]; then
215
218
  ALL_COLORS+=("${RAINBOW[$(( (_arc + RAINBOW_OFFSET) % RAINBOW_LEN ))]}")
216
219
  else
217
- ALL_COLORS+=("$C")
220
+ ALL_COLORS+=("$NEUTRAL")
218
221
  fi
219
222
  _arc=$(( _arc + 1 ))
220
223
  done
221
- ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$DIM")
224
+ ALL_LINES+=("$NAME_LINE"); ALL_COLORS+=("$C")
222
225
 
223
226
  case "$SPECIES" in
224
227
  axolotl|capybara|rabbit|mushroom|chonk) ART_W=10 ;;
@@ -377,18 +380,18 @@ for (( i=0; i<MAX_LINES; i++ )); do
377
380
 
378
381
  # Connector: "-- " on the middle text line, spaces otherwise
379
382
  if [ $bi -eq $CONNECTOR_BI ]; then
380
- gap="${C}--${NC} "
383
+ gap="${NEUTRAL}--${NC} "
381
384
  else
382
385
  gap=" "
383
386
  fi
384
387
 
385
388
  if [ "$btype" = "border" ]; then
386
- echo "${SPACER}${C}${bline}${NC}${gap}${art_part}"
389
+ echo "${SPACER}${NEUTRAL}${bline}${NC}${gap}${art_part}"
387
390
  else
388
391
  pipe_l="${bline:0:1}"
389
392
  pipe_r="${bline: -1}"
390
393
  inner="${bline:1:$(( ${#bline} - 2 ))}"
391
- echo "${SPACER}${C}${pipe_l}${NC}${DIM}${inner}${NC}${C}${pipe_r}${NC}${gap}${art_part}"
394
+ echo "${SPACER}${NEUTRAL}${pipe_l}${NC}${DIM}${inner}${NC}${NEUTRAL}${pipe_r}${NC}${gap}${art_part}"
392
395
  fi
393
396
  else
394
397
  empty=$(printf '%*s' "$BOX_W" '')