@ramarivera/coding-buddy 0.4.0-alpha.9 → 0.7.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 +45 -0
- package/.claude-plugin/plugin.json +30 -0
- package/LICENSE +1 -1
- package/README.md +96 -45
- package/adapters/omp/commands.ts +201 -0
- package/adapters/omp/context.ts +22 -0
- package/adapters/omp/events.ts +551 -0
- package/adapters/omp/identity.ts +10 -0
- package/adapters/omp/index.ts +39 -0
- package/adapters/omp/logger.ts +52 -0
- package/adapters/omp/prompt.ts +60 -0
- package/adapters/omp/renderers.ts +73 -0
- package/adapters/omp/storage.ts +25 -0
- package/adapters/omp/ui.ts +85 -0
- package/adapters/pi/commands.ts +64 -37
- package/adapters/pi/events.ts +147 -72
- package/adapters/pi/index.ts +9 -4
- package/adapters/pi/storage.ts +14 -285
- package/adapters/pi/ui.ts +56 -5
- package/adapters/shared/file-storage.ts +526 -0
- package/{adapters/claude/install → cli}/backup.ts +130 -33
- package/cli/buddy-shell.ts +1 -1
- package/{adapters/claude/install → cli}/disable.ts +19 -32
- package/cli/doctor.ts +231 -0
- package/{adapters/claude/install → cli}/hunt.ts +7 -9
- package/cli/index.ts +30 -26
- package/{adapters/claude/install → cli}/install.ts +65 -83
- package/{adapters/claude/install → cli}/pick.ts +136 -57
- package/{adapters/claude/install → cli}/settings.ts +2 -2
- package/{adapters/claude/install → cli}/show.ts +4 -4
- package/{adapters/claude/install → cli}/test-statusline.sh +2 -2
- package/{adapters/claude/install → cli}/test-statusline.ts +21 -11
- package/cli/tui.tsx +106 -17
- package/{adapters/claude/install → cli}/uninstall.ts +40 -47
- package/cli/upgrade.ts +46 -13
- package/cli/validate-species.ts +209 -0
- package/cli/verify.ts +3 -3
- package/core/achievements.ts +12 -20
- package/core/art-data.ts +26 -0
- package/core/command-service.ts +99 -97
- package/core/engine.ts +83 -1
- package/core/ports.ts +10 -0
- package/core/reactions.ts +533 -95
- package/core/slot-slug.ts +22 -0
- package/{adapters/claude/hooks → hooks}/buddy-comment.sh +22 -9
- package/hooks/file-type-react.sh +403 -0
- package/hooks/hooks.json +52 -0
- package/hooks/mood-react.sh +210 -0
- package/{adapters/claude/hooks → hooks}/name-react.sh +3 -4
- package/hooks/react.sh +1364 -0
- package/hooks/suggest.sh +55 -0
- package/package.json +70 -31
- package/scripts/gen-emoji-widths.ts +34 -0
- package/scripts/paths.sh +33 -2
- package/scripts/postinstall.cjs +42 -0
- package/scripts/snapshot-statusline.sh +54 -0
- package/server/achievements.ts +1419 -15
- package/server/art.ts +450 -1
- package/server/award-xp.ts +48 -0
- package/server/check-suggestions.ts +102 -0
- package/server/consolidate.ts +33 -0
- package/{adapters/claude/server → server}/index.ts +556 -95
- package/server/mcp-launcher.sh +16 -6
- package/server/memory.ts +404 -0
- package/server/mood.ts +254 -0
- package/server/path.ts +70 -18
- package/server/shift-mood.ts +43 -0
- package/server/state.ts +552 -3
- package/server/suggestions.ts +335 -0
- package/server/theme.ts +53 -0
- package/server/xp.ts +435 -0
- package/{adapters/claude/skills → skills}/buddy/SKILL.md +36 -7
- package/statusline/buddy-status.sh +402 -0
- package/statusline/combined-status.sh +135 -0
- package/statusline/emoji-widths.data +4 -0
- package/adapters/claude/hooks/hooks.json +0 -35
- package/adapters/claude/hooks/react.sh +0 -207
- package/adapters/claude/install/doctor.ts +0 -172
- package/adapters/claude/plugin/marketplace.json +0 -40
- package/adapters/claude/plugin/plugin.json +0 -26
- package/adapters/claude/rendering/art.ts +0 -264
- package/adapters/claude/server/instructions.ts +0 -24
- package/adapters/claude/server/path.ts +0 -12
- package/adapters/claude/server/resources.ts +0 -38
- package/adapters/claude/statusline/buddy-status.sh +0 -408
- package/adapters/claude/storage/achievements.ts +0 -253
- package/adapters/claude/storage/identity.ts +0 -13
- package/adapters/claude/storage/paths.ts +0 -33
- package/adapters/claude/storage/settings.ts +0 -91
- package/adapters/claude/storage/state.ts +0 -365
- package/adapters/pi/README.md +0 -83
- package/adapters/pi/tools.ts +0 -6
- package/server/engine.ts +0 -1
- package/server/reactions.ts +0 -1
package/server/path.ts
CHANGED
|
@@ -1,30 +1,82 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
1
|
+
// Path utilities and helpers
|
|
2
|
+
//
|
|
3
|
+
// Two related concerns live here:
|
|
4
|
+
// 1. Path normalization (Windows compat) — toUnixPath().
|
|
5
|
+
// 2. Resolution of Claude Code config / state paths — claudeConfigDir,
|
|
6
|
+
// claudeSettingsPath, claudeSkillDir, claudeUserConfigPath, buddyStateDir.
|
|
7
|
+
// These honor CLAUDE_CONFIG_DIR so coding-buddy works with Claude Code's
|
|
8
|
+
// multi-account layout (one CLAUDE_CONFIG_DIR per profile).
|
|
9
|
+
//
|
|
10
|
+
// The shell counterpart of (2) lives in scripts/paths.sh and MUST stay in sync.
|
|
11
|
+
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
|
|
15
|
+
// ─── (1) Path normalization ─────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
// Node's path.join() produces backslash paths on Windows, which bash treats as
|
|
18
|
+
// escape sequences, stripping them entirely (e.g. C:\Users -> C:Users).
|
|
19
|
+
// Use forward slashes in all paths written to config files.
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Converts all backslashes in a file path to forward slashes, producing a Unix-style path.
|
|
23
|
+
*
|
|
24
|
+
* @param p - The file path to convert.
|
|
25
|
+
* @returns The converted path with forward slashes.
|
|
26
|
+
*/
|
|
27
|
+
export function toUnixPath(p: string): string {
|
|
28
|
+
return p.replace(/\\/g, "/");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ─── (2) Claude config / state path resolvers ───────────────────────────────
|
|
32
|
+
//
|
|
33
|
+
// Resolution rules:
|
|
34
|
+
// - If CLAUDE_CONFIG_DIR is set: everything lives under
|
|
35
|
+
// $CLAUDE_CONFIG_DIR/. .claude.json is preferred inside the config dir
|
|
36
|
+
// when present, falling back to $HOME/.claude.json for setups that
|
|
37
|
+
// keep it at $HOME. Buddy state goes to $CLAUDE_CONFIG_DIR/buddy-state.
|
|
38
|
+
// - If CLAUDE_CONFIG_DIR is NOT set: the single-profile defaults —
|
|
39
|
+
// ~/.claude/, ~/.claude.json, ~/.claude-buddy/.
|
|
40
|
+
|
|
41
|
+
function envDir(name: string): string | undefined {
|
|
42
|
+
const v = process.env[name];
|
|
43
|
+
return v && v.length > 0 ? v : undefined;
|
|
44
|
+
}
|
|
9
45
|
|
|
10
46
|
export function claudeConfigDir(): string {
|
|
11
|
-
return
|
|
47
|
+
return envDir("CLAUDE_CONFIG_DIR") ?? join(homedir(), ".claude");
|
|
12
48
|
}
|
|
13
49
|
|
|
14
50
|
export function claudeSettingsPath(): string {
|
|
15
|
-
return
|
|
51
|
+
return join(claudeConfigDir(), "settings.json");
|
|
16
52
|
}
|
|
17
53
|
|
|
18
|
-
export function
|
|
19
|
-
return
|
|
54
|
+
export function claudeSkillDir(name: string): string {
|
|
55
|
+
return join(claudeConfigDir(), "skills", name);
|
|
20
56
|
}
|
|
21
57
|
|
|
22
|
-
|
|
23
|
-
|
|
58
|
+
/**
|
|
59
|
+
* Resolve the path to Claude Code's user-config file (.claude.json).
|
|
60
|
+
*
|
|
61
|
+
* When CLAUDE_CONFIG_DIR is set the file lives inside it — we always
|
|
62
|
+
* resolve there so every read and write stays profile-scoped. Falling
|
|
63
|
+
* back to $HOME/.claude.json when the in-dir file is missing would
|
|
64
|
+
* break that isolation: installs in one profile could mutate the
|
|
65
|
+
* home-level file that a different profile reads. If the in-dir file
|
|
66
|
+
* doesn't exist yet, callers surface a clear "Start Claude Code once
|
|
67
|
+
* first" preflight error instead of silently writing to $HOME.
|
|
68
|
+
*
|
|
69
|
+
* When CLAUDE_CONFIG_DIR is unset we use $HOME/.claude.json — the
|
|
70
|
+
* single-profile default.
|
|
71
|
+
*/
|
|
72
|
+
export function claudeUserConfigPath(): string {
|
|
73
|
+
const cfgDir = envDir("CLAUDE_CONFIG_DIR");
|
|
74
|
+
if (cfgDir) return join(cfgDir, ".claude.json");
|
|
75
|
+
return join(homedir(), ".claude.json");
|
|
24
76
|
}
|
|
25
77
|
|
|
26
|
-
export function
|
|
27
|
-
|
|
78
|
+
export function buddyStateDir(): string {
|
|
79
|
+
const cfgDir = envDir("CLAUDE_CONFIG_DIR");
|
|
80
|
+
if (cfgDir) return join(cfgDir, "buddy-state");
|
|
81
|
+
return join(homedir(), ".claude-buddy");
|
|
28
82
|
}
|
|
29
|
-
|
|
30
|
-
export { toUnixPath };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Lightweight mood shifting script — called from shell hooks.
|
|
4
|
+
* Shifts mood based on coding events.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun run server/shift-mood.ts <trigger> [slot]
|
|
8
|
+
*
|
|
9
|
+
* Triggers: tests_pass | tests_fail | error | large_diff | achievement | long_session | idle | rest
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { shiftMood, MOOD_NAMES, MOOD_COLORS } from "./mood";
|
|
13
|
+
import type { MoodTrigger } from "./mood";
|
|
14
|
+
|
|
15
|
+
const VALID_TRIGGERS = new Set([
|
|
16
|
+
"tests_pass",
|
|
17
|
+
"tests_fail",
|
|
18
|
+
"error",
|
|
19
|
+
"large_diff",
|
|
20
|
+
"achievement",
|
|
21
|
+
"long_session",
|
|
22
|
+
"idle",
|
|
23
|
+
"rest",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function main(): void {
|
|
27
|
+
const trigger = process.argv[2] as string;
|
|
28
|
+
|
|
29
|
+
if (!trigger || !VALID_TRIGGERS.has(trigger)) {
|
|
30
|
+
console.error(
|
|
31
|
+
`Usage: bun run server/shift-mood.ts <trigger>\nValid triggers: ${[...VALID_TRIGGERS].join(" | ")}`,
|
|
32
|
+
);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const state = shiftMood(trigger as MoodTrigger);
|
|
37
|
+
const mood = state.current;
|
|
38
|
+
const color = MOOD_COLORS[mood] ?? "💫";
|
|
39
|
+
const name = MOOD_NAMES[mood] ?? mood;
|
|
40
|
+
console.log(`Mood: ${color} ${name} (intensity ${state.intensity}/3)`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
main();
|
package/server/state.ts
CHANGED
|
@@ -1,3 +1,552 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
/**
|
|
2
|
+
* State management — reads/writes companion data to the buddy state dir.
|
|
3
|
+
*
|
|
4
|
+
* The state dir resolves via server/paths.ts (honors CLAUDE_CONFIG_DIR).
|
|
5
|
+
* Default: ~/.claude-buddy/. With CLAUDE_CONFIG_DIR set:
|
|
6
|
+
* $CLAUDE_CONFIG_DIR/buddy-state/.
|
|
7
|
+
*
|
|
8
|
+
* Storage layout (v3 — single manifest):
|
|
9
|
+
* <state-dir>/
|
|
10
|
+
* menagerie.json <- SSOT: { active, companions: { [slot]: Companion } }
|
|
11
|
+
* reaction.$SID.json <- transient reaction state (session-scoped)
|
|
12
|
+
* status.json <- compact state for the status-line shell script
|
|
13
|
+
* config.json <- user preferences (cooldown, bubble style, etc.)
|
|
14
|
+
*
|
|
15
|
+
* Rules:
|
|
16
|
+
* - saveCompanionSlot() APPENDS only — throws if the slot already exists
|
|
17
|
+
* - saveCompanion() UPDATES the currently-active slot (rename / personality)
|
|
18
|
+
* - All manifest writes are atomic (write tmp -> rename)
|
|
19
|
+
*
|
|
20
|
+
* Combined: PR #4 menagerie + PR #6 session isolation + config
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import {
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
mkdirSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
readdirSync,
|
|
29
|
+
renameSync,
|
|
30
|
+
rmSync,
|
|
31
|
+
} from "fs";
|
|
32
|
+
import { join } from "path";
|
|
33
|
+
import type { Companion } from "../core/engine.ts"
|
|
34
|
+
import {
|
|
35
|
+
buddyStateDir,
|
|
36
|
+
claudeSettingsPath,
|
|
37
|
+
claudeUserConfigPath,
|
|
38
|
+
toUnixPath,
|
|
39
|
+
} from "./path.ts";
|
|
40
|
+
|
|
41
|
+
export const STATE_DIR = buddyStateDir();
|
|
42
|
+
const MANIFEST_FILE = join(STATE_DIR, "menagerie.json");
|
|
43
|
+
const CONFIG_FILE = join(STATE_DIR, "config.json");
|
|
44
|
+
|
|
45
|
+
// ─── Session ID (Claude Code session isolation, tmux pane fallback) ─────────
|
|
46
|
+
//
|
|
47
|
+
// Precedence:
|
|
48
|
+
// 1. $CLAUDE_CODE_SESSION_ID — Claude Code sets a UUID per session and
|
|
49
|
+
// propagates it to the MCP server, hooks, and statusline. Shortened
|
|
50
|
+
// to first 8 chars to keep filenames readable.
|
|
51
|
+
// 2. $TMUX_PANE — legacy tmux-pane isolation kept as a fallback.
|
|
52
|
+
// 3. "default" — single-session world.
|
|
53
|
+
//
|
|
54
|
+
// Must match the bash equivalent in scripts/paths.sh.
|
|
55
|
+
|
|
56
|
+
function sessionId(): string {
|
|
57
|
+
const ccSid = process.env.CLAUDE_CODE_SESSION_ID;
|
|
58
|
+
if (ccSid && ccSid.length > 0) return ccSid.slice(0, 8);
|
|
59
|
+
const pane = process.env.TMUX_PANE;
|
|
60
|
+
if (!pane) return "default";
|
|
61
|
+
return pane.replace(/^%/, "");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function reactionFile(): string {
|
|
65
|
+
return join(STATE_DIR, `reaction.${sessionId()}.json`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ─── Manifest schema ─────────────────────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
interface Manifest {
|
|
71
|
+
active: string;
|
|
72
|
+
companions: Record<string, Companion>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function emptyManifest(): Manifest {
|
|
76
|
+
return { active: "buddy", companions: {} };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ─── Atomic manifest I/O ─────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function loadManifest(): Manifest {
|
|
82
|
+
try {
|
|
83
|
+
const raw = readFileSync(MANIFEST_FILE, "utf8");
|
|
84
|
+
const m = JSON.parse(raw) as Manifest;
|
|
85
|
+
if (!m.companions) m.companions = {};
|
|
86
|
+
return m;
|
|
87
|
+
} catch {
|
|
88
|
+
return emptyManifest();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function saveManifest(m: Manifest): void {
|
|
93
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
94
|
+
const tmp = MANIFEST_FILE + ".tmp";
|
|
95
|
+
writeFileSync(tmp, JSON.stringify(m, null, 2));
|
|
96
|
+
renameSync(tmp, MANIFEST_FILE); // atomic on same filesystem
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Slot helpers ────────────────────────────────────────────────────────────
|
|
100
|
+
|
|
101
|
+
/** Normalise a string to a safe slot key (a-z0-9-, max 14 chars). */
|
|
102
|
+
export function slugify(name: string): string {
|
|
103
|
+
return (
|
|
104
|
+
name
|
|
105
|
+
.toLowerCase()
|
|
106
|
+
.replace(/[^a-z0-9-]/g, "-")
|
|
107
|
+
.replace(/-+/g, "-")
|
|
108
|
+
.replace(/^-|-$/g, "")
|
|
109
|
+
.slice(0, 14) || "buddy"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Return a random fallback name whose slug is not already in the manifest.
|
|
115
|
+
* Falls back to "buddy-<random 3 digits>" if all names are taken.
|
|
116
|
+
*/
|
|
117
|
+
export function unusedName(): string {
|
|
118
|
+
const { generateFallbackName } =
|
|
119
|
+
require("../core/reactions.ts") as typeof import("../core/reactions.ts");
|
|
120
|
+
const taken = new Set(Object.keys(loadManifest().companions));
|
|
121
|
+
for (let i = 0; i < 50; i++) {
|
|
122
|
+
const n = generateFallbackName();
|
|
123
|
+
if (!taken.has(slugify(n))) return n;
|
|
124
|
+
}
|
|
125
|
+
let suffix = 0;
|
|
126
|
+
while (taken.has(`buddy-${suffix}`)) suffix++;
|
|
127
|
+
return `buddy-${suffix}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ─── Active slot ─────────────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
export function loadActiveSlot(): string {
|
|
133
|
+
const m = loadManifest();
|
|
134
|
+
if (m.active && m.companions[m.active]) return m.active;
|
|
135
|
+
const first = Object.keys(m.companions)[0];
|
|
136
|
+
if (first) {
|
|
137
|
+
m.active = first;
|
|
138
|
+
saveManifest(m);
|
|
139
|
+
return first;
|
|
140
|
+
}
|
|
141
|
+
return "buddy";
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function saveActiveSlot(slot: string): void {
|
|
145
|
+
const m = loadManifest();
|
|
146
|
+
m.active = slot;
|
|
147
|
+
saveManifest(m);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Companion slot API ───────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export function loadCompanionSlot(slot: string): Companion | null {
|
|
153
|
+
return loadManifest().companions[slot] ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* APPEND a new companion to the manifest.
|
|
158
|
+
* Throws if the slot already exists — use saveCompanion() to update an existing buddy.
|
|
159
|
+
*/
|
|
160
|
+
export function saveCompanionSlot(companion: Companion, slot: string): void {
|
|
161
|
+
const m = loadManifest();
|
|
162
|
+
if (m.companions[slot]) {
|
|
163
|
+
throw new Error(`Slot "${slot}" already exists. Choose a different name.`);
|
|
164
|
+
}
|
|
165
|
+
m.companions[slot] = companion;
|
|
166
|
+
saveManifest(m);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* UPDATE an existing (possibly non-active) companion slot.
|
|
171
|
+
* Throws if the slot does not exist.
|
|
172
|
+
*/
|
|
173
|
+
export function updateCompanionSlot(slot: string, companion: Companion): void {
|
|
174
|
+
const m = loadManifest();
|
|
175
|
+
if (!m.companions[slot]) {
|
|
176
|
+
throw new Error(`Slot "${slot}" does not exist.`);
|
|
177
|
+
}
|
|
178
|
+
m.companions[slot] = companion;
|
|
179
|
+
saveManifest(m);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function deleteCompanionSlot(slot: string): void {
|
|
183
|
+
const m = loadManifest();
|
|
184
|
+
delete m.companions[slot];
|
|
185
|
+
if (m.active === slot) {
|
|
186
|
+
m.active = Object.keys(m.companions)[0] ?? "buddy";
|
|
187
|
+
}
|
|
188
|
+
saveManifest(m);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function listCompanionSlots(): Array<{
|
|
192
|
+
slot: string;
|
|
193
|
+
companion: Companion;
|
|
194
|
+
}> {
|
|
195
|
+
return Object.entries(loadManifest().companions).map(([slot, companion]) => ({
|
|
196
|
+
slot,
|
|
197
|
+
companion,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ─── Primary companion API ────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
export function loadCompanion(): Companion | null {
|
|
204
|
+
migrateIfNeeded();
|
|
205
|
+
const m = loadManifest();
|
|
206
|
+
return m.companions[m.active] ?? null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* UPDATE the currently-active companion (rename, personality changes, etc.).
|
|
211
|
+
* This is the ONLY intentional in-place update path.
|
|
212
|
+
*/
|
|
213
|
+
export function saveCompanion(companion: Companion): void {
|
|
214
|
+
const m = loadManifest();
|
|
215
|
+
m.companions[m.active] = companion;
|
|
216
|
+
saveManifest(m);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ─── Migration: legacy companion.json -> single manifest ────────────────────
|
|
220
|
+
|
|
221
|
+
function migrateIfNeeded(): void {
|
|
222
|
+
if (existsSync(MANIFEST_FILE)) return;
|
|
223
|
+
|
|
224
|
+
const companions: Record<string, Companion> = {};
|
|
225
|
+
let active = "buddy";
|
|
226
|
+
|
|
227
|
+
// Absorb menagerie/<slot>.json files
|
|
228
|
+
const menagerieDir = join(STATE_DIR, "menagerie");
|
|
229
|
+
if (existsSync(menagerieDir)) {
|
|
230
|
+
try {
|
|
231
|
+
for (const f of readdirSync(menagerieDir).filter((f) =>
|
|
232
|
+
f.endsWith(".json"),
|
|
233
|
+
)) {
|
|
234
|
+
const slot = f.slice(0, -5);
|
|
235
|
+
try {
|
|
236
|
+
companions[slot] = JSON.parse(
|
|
237
|
+
readFileSync(join(menagerieDir, f), "utf8"),
|
|
238
|
+
);
|
|
239
|
+
} catch {
|
|
240
|
+
/* skip malformed */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
} catch {
|
|
244
|
+
/* noop */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Absorb legacy companion.json
|
|
249
|
+
const legacyFile = join(STATE_DIR, "companion.json");
|
|
250
|
+
if (existsSync(legacyFile) && Object.keys(companions).length === 0) {
|
|
251
|
+
try {
|
|
252
|
+
const c: Companion = JSON.parse(readFileSync(legacyFile, "utf8"));
|
|
253
|
+
const slot = slugify(c.name);
|
|
254
|
+
companions[slot] = c;
|
|
255
|
+
active = slot;
|
|
256
|
+
} catch {
|
|
257
|
+
/* noop */
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Read active pointer if it exists
|
|
262
|
+
const activeFile = join(STATE_DIR, "active");
|
|
263
|
+
if (existsSync(activeFile)) {
|
|
264
|
+
try {
|
|
265
|
+
const a = readFileSync(activeFile, "utf8").trim();
|
|
266
|
+
if (a && companions[a]) active = a;
|
|
267
|
+
} catch {
|
|
268
|
+
/* noop */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (Object.keys(companions).length > 0) {
|
|
273
|
+
active = active && companions[active] ? active : Object.keys(companions)[0];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
saveManifest({ active, companions });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── Reaction state (session-scoped for tmux isolation) ──────────────────────
|
|
280
|
+
|
|
281
|
+
export interface ReactionState {
|
|
282
|
+
reaction: string;
|
|
283
|
+
timestamp: number;
|
|
284
|
+
reason: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function loadReaction(): ReactionState | null {
|
|
288
|
+
try {
|
|
289
|
+
const data: ReactionState = JSON.parse(readFileSync(reactionFile(), "utf8"));
|
|
290
|
+
const { reactionTTL } = loadConfig();
|
|
291
|
+
if (reactionTTL > 0 && Date.now() - data.timestamp > reactionTTL * 1000) return null;
|
|
292
|
+
return data;
|
|
293
|
+
} catch {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function saveReaction(reaction: string, reason: string): void {
|
|
299
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
300
|
+
const state: ReactionState = { reaction, timestamp: Date.now(), reason };
|
|
301
|
+
writeFileSync(reactionFile(), JSON.stringify(state));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ─── Identity resolution ─────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
export function resolveUserId(): string {
|
|
307
|
+
try {
|
|
308
|
+
const claudeJson = JSON.parse(readFileSync(claudeUserConfigPath(), "utf8"));
|
|
309
|
+
return claudeJson.oauthAccount?.accountUuid ?? claudeJson.userID ?? "anon";
|
|
310
|
+
} catch {
|
|
311
|
+
return "anon";
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// ─── Config persistence (PR #6: tmux popup settings) ─────────────────────────
|
|
316
|
+
|
|
317
|
+
export interface BuddyConfig {
|
|
318
|
+
commentCooldown: number;
|
|
319
|
+
reactionTTL: number;
|
|
320
|
+
bubbleStyle: "classic" | "round";
|
|
321
|
+
bubblePosition: "top" | "left";
|
|
322
|
+
showRarity: boolean;
|
|
323
|
+
statusLineEnabled: boolean;
|
|
324
|
+
bubbleWidth: number;
|
|
325
|
+
bubbleMargin: number;
|
|
326
|
+
useCombinedStatus: boolean;
|
|
327
|
+
rainbowColors?: string[];
|
|
328
|
+
theme: "dark" | "light" | "auto";
|
|
329
|
+
moodEnabled: boolean;
|
|
330
|
+
memoryEnabled: boolean;
|
|
331
|
+
suggestionsEnabled: boolean;
|
|
332
|
+
suggestionCooldown: number;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const DEFAULT_CONFIG: BuddyConfig = {
|
|
336
|
+
commentCooldown: 30,
|
|
337
|
+
reactionTTL: 0,
|
|
338
|
+
bubbleStyle: "classic",
|
|
339
|
+
bubblePosition: "top",
|
|
340
|
+
showRarity: true,
|
|
341
|
+
statusLineEnabled: false,
|
|
342
|
+
bubbleWidth: 28,
|
|
343
|
+
bubbleMargin: 8,
|
|
344
|
+
useCombinedStatus: false,
|
|
345
|
+
theme: "auto",
|
|
346
|
+
moodEnabled: true,
|
|
347
|
+
memoryEnabled: true,
|
|
348
|
+
suggestionsEnabled: true,
|
|
349
|
+
suggestionCooldown: 180,
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
export function loadConfig(): BuddyConfig {
|
|
353
|
+
try {
|
|
354
|
+
const data = JSON.parse(readFileSync(CONFIG_FILE, "utf8"));
|
|
355
|
+
return { ...DEFAULT_CONFIG, ...data };
|
|
356
|
+
} catch {
|
|
357
|
+
return { ...DEFAULT_CONFIG };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function saveConfig(config: Partial<BuddyConfig>): BuddyConfig {
|
|
362
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
363
|
+
const current = loadConfig();
|
|
364
|
+
const merged = { ...current, ...config };
|
|
365
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2));
|
|
366
|
+
return merged;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// ─── Status line state (compact JSON for the shell script) ───────────────────
|
|
370
|
+
|
|
371
|
+
export interface StatusState {
|
|
372
|
+
name: string;
|
|
373
|
+
species: string;
|
|
374
|
+
rarity: string;
|
|
375
|
+
stars: string;
|
|
376
|
+
face: string;
|
|
377
|
+
eye: string;
|
|
378
|
+
shiny: boolean;
|
|
379
|
+
hat: string;
|
|
380
|
+
reaction: string;
|
|
381
|
+
muted: boolean;
|
|
382
|
+
achievement: string;
|
|
383
|
+
frames: string[];
|
|
384
|
+
frameSequence: number[];
|
|
385
|
+
level: number;
|
|
386
|
+
xp: number;
|
|
387
|
+
mood: string;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function writeStatusState(
|
|
391
|
+
companion: Companion,
|
|
392
|
+
reaction?: string,
|
|
393
|
+
muted?: boolean,
|
|
394
|
+
achievement?: string,
|
|
395
|
+
level?: number,
|
|
396
|
+
xp?: number,
|
|
397
|
+
): void {
|
|
398
|
+
mkdirSync(STATE_DIR, { recursive: true });
|
|
399
|
+
const { renderFace, RARITY_STARS } =
|
|
400
|
+
require("../core/engine.ts") as typeof import("../core/engine.ts");
|
|
401
|
+
const { getStatusFrames } =
|
|
402
|
+
require("./art.ts") as typeof import("./art.ts");
|
|
403
|
+
const { frames, frameSequence } = getStatusFrames(companion.bones);
|
|
404
|
+
let xpLevel = level ?? 1;
|
|
405
|
+
let xpTotal = xp ?? 0;
|
|
406
|
+
let moodStr = "focused";
|
|
407
|
+
try {
|
|
408
|
+
const { getXpState } = require("./xp.ts") as typeof import("./xp.ts");
|
|
409
|
+
const xpState = getXpState();
|
|
410
|
+
xpLevel = xpState.level;
|
|
411
|
+
xpTotal = xpState.totalXp;
|
|
412
|
+
} catch {
|
|
413
|
+
// XP state is optional during first install / version skew.
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
const { getMood } = require("./mood.ts") as typeof import("./mood.ts");
|
|
417
|
+
moodStr = getMood().current;
|
|
418
|
+
} catch {
|
|
419
|
+
// Mood state is optional during first install / version skew.
|
|
420
|
+
}
|
|
421
|
+
const state: StatusState = {
|
|
422
|
+
name: companion.name,
|
|
423
|
+
species: companion.bones.species,
|
|
424
|
+
rarity: companion.bones.rarity,
|
|
425
|
+
stars: RARITY_STARS[companion.bones.rarity],
|
|
426
|
+
face: renderFace(companion.bones.species, companion.bones.eye),
|
|
427
|
+
eye: companion.bones.eye,
|
|
428
|
+
shiny: companion.bones.shiny,
|
|
429
|
+
hat: companion.bones.hat,
|
|
430
|
+
reaction: "",
|
|
431
|
+
muted: muted ?? false,
|
|
432
|
+
achievement: achievement ?? "",
|
|
433
|
+
frames,
|
|
434
|
+
frameSequence,
|
|
435
|
+
level: xpLevel,
|
|
436
|
+
xp: xpTotal,
|
|
437
|
+
mood: moodStr,
|
|
438
|
+
};
|
|
439
|
+
writeFileSync(join(STATE_DIR, "status.json"), JSON.stringify(state));
|
|
440
|
+
if (reaction) saveReaction(reaction, "mcp");
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ─── Claude Code settings.json patching (for buddy_statusline tool) ──────────
|
|
444
|
+
|
|
445
|
+
export const CLAUDE_SETTINGS_PATH = claudeSettingsPath();
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Write settings.statusLine pointing to the given buddy-status script.
|
|
449
|
+
* Atomic via tmp + rename. Returns false if settings.json is unreachable.
|
|
450
|
+
*/
|
|
451
|
+
export function setBuddyStatusLine(
|
|
452
|
+
statusScript: string,
|
|
453
|
+
settingsPath: string = CLAUDE_SETTINGS_PATH,
|
|
454
|
+
): boolean {
|
|
455
|
+
try {
|
|
456
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
457
|
+
settings.statusLine = {
|
|
458
|
+
type: "command",
|
|
459
|
+
command: toUnixPath(statusScript),
|
|
460
|
+
padding: 1,
|
|
461
|
+
refreshInterval: 1,
|
|
462
|
+
};
|
|
463
|
+
const tmp = settingsPath + ".tmp";
|
|
464
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
465
|
+
renameSync(tmp, settingsPath);
|
|
466
|
+
return true;
|
|
467
|
+
} catch {
|
|
468
|
+
return false;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/**
|
|
473
|
+
* Remove settings.statusLine — but only if it points to buddy-status.sh.
|
|
474
|
+
* Leaves foreign statusLines untouched. Returns false if no buddy line was
|
|
475
|
+
* present or settings.json is unreachable.
|
|
476
|
+
*/
|
|
477
|
+
export function unsetBuddyStatusLine(
|
|
478
|
+
settingsPath: string = CLAUDE_SETTINGS_PATH,
|
|
479
|
+
): boolean {
|
|
480
|
+
try {
|
|
481
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
482
|
+
if (!settings.statusLine?.command?.includes("buddy-status.sh")) return false;
|
|
483
|
+
delete settings.statusLine;
|
|
484
|
+
const tmp = settingsPath + ".tmp";
|
|
485
|
+
writeFileSync(tmp, JSON.stringify(settings, null, 2) + "\n");
|
|
486
|
+
renameSync(tmp, settingsPath);
|
|
487
|
+
return true;
|
|
488
|
+
} catch {
|
|
489
|
+
return false;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ─── Plugin uninstall cleanup ───────────────────────────────────────────────
|
|
494
|
+
|
|
495
|
+
export interface CleanupResult {
|
|
496
|
+
statusLineRemoved: boolean;
|
|
497
|
+
foreignStatusLineKept: boolean;
|
|
498
|
+
transientFilesRemoved: number;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const TRANSIENT_PREFIXES = [
|
|
502
|
+
"popup-stop.",
|
|
503
|
+
"popup-resize.",
|
|
504
|
+
"popup-env.",
|
|
505
|
+
"popup-scroll.",
|
|
506
|
+
"popup-reopen-pid.",
|
|
507
|
+
"reaction.",
|
|
508
|
+
".last_reaction.",
|
|
509
|
+
".last_comment.",
|
|
510
|
+
];
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Clean up plugin-owned writes to the user's global state so that
|
|
514
|
+
* `claude plugin uninstall` leaves no orphaned entries behind. Specifically:
|
|
515
|
+
* - remove settings.statusLine if it points to buddy-status.sh
|
|
516
|
+
* - delete session-scoped transient files under ~/.claude-buddy/
|
|
517
|
+
*
|
|
518
|
+
* Companion data (menagerie.json, status.json, config.json) is intentionally
|
|
519
|
+
* kept — users reinstalling get their buddy back. Call-sites that want a full
|
|
520
|
+
* wipe should delete STATE_DIR themselves after calling this.
|
|
521
|
+
*/
|
|
522
|
+
export function cleanupPluginState(
|
|
523
|
+
settingsPath: string = CLAUDE_SETTINGS_PATH,
|
|
524
|
+
stateDir: string = STATE_DIR,
|
|
525
|
+
): CleanupResult {
|
|
526
|
+
const statusLineRemoved = unsetBuddyStatusLine(settingsPath);
|
|
527
|
+
|
|
528
|
+
let foreignStatusLineKept = false;
|
|
529
|
+
try {
|
|
530
|
+
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
|
|
531
|
+
const cmd = settings.statusLine?.command;
|
|
532
|
+
if (cmd && !cmd.includes("buddy-status.sh")) foreignStatusLineKept = true;
|
|
533
|
+
} catch {
|
|
534
|
+
/* missing settings.json is fine */
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let transientFilesRemoved = 0;
|
|
538
|
+
try {
|
|
539
|
+
if (existsSync(stateDir)) {
|
|
540
|
+
for (const f of readdirSync(stateDir)) {
|
|
541
|
+
if (TRANSIENT_PREFIXES.some(p => f.startsWith(p))) {
|
|
542
|
+
rmSync(join(stateDir, f), { force: true });
|
|
543
|
+
transientFilesRemoved++;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
} catch {
|
|
548
|
+
/* state dir unreadable is fine */
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
return { statusLineRemoved, foreignStatusLineKept, transientFilesRemoved };
|
|
552
|
+
}
|