opencode-buddy 0.2.1 → 0.3.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/README.md +77 -156
- package/package.json +8 -20
- package/src/persistence.js +7 -7
- package/src/server-plugin.js +133 -0
- package/src/species.js +541 -0
- package/src/state.js +120 -0
- package/src/tui-plugin.jsx +156 -0
- package/bin/opencode-buddy.js +0 -45
- package/src/buddy/species.js +0 -399
- package/src/buddy/state.js +0 -168
- package/src/index.js +0 -332
- package/src/install.js +0 -157
- package/src/plugin.js +0 -178
- package/src/tmux.js +0 -90
- package/src/tui.js +0 -164
package/src/buddy/state.js
DELETED
|
@@ -1,168 +0,0 @@
|
|
|
1
|
-
// Buddy state machine + attribute bookkeeping.
|
|
2
|
-
//
|
|
3
|
-
// Attributes (all 0..100):
|
|
4
|
-
// hunger 100 = full, 0 = starving
|
|
5
|
-
// happiness 100 = delighted, 0 = grumpy
|
|
6
|
-
// energy 100 = wide awake, 0 = exhausted
|
|
7
|
-
//
|
|
8
|
-
// External events from opencode (via tmux pane polling or CLI notify):
|
|
9
|
-
// working -> state becomes 'working' (boosts happiness over time)
|
|
10
|
-
// done -> state becomes 'celebrating' for a few seconds
|
|
11
|
-
// error -> state becomes 'scared' for a few seconds
|
|
12
|
-
// idle -> state returns to 'idle' / 'sleeping' based on energy
|
|
13
|
-
//
|
|
14
|
-
// User actions:
|
|
15
|
-
// feed -> +25 hunger, -5 energy
|
|
16
|
-
// play -> +20 happiness, -10 energy, -5 hunger
|
|
17
|
-
// rest -> +30 energy
|
|
18
|
-
//
|
|
19
|
-
// Time decay: every minute, hunger -0.5, happiness -0.3, energy -0.2.
|
|
20
|
-
|
|
21
|
-
export const DEFAULT_STATE = {
|
|
22
|
-
species: "duck",
|
|
23
|
-
name: "Quack",
|
|
24
|
-
hunger: 80,
|
|
25
|
-
happiness: 80,
|
|
26
|
-
energy: 100,
|
|
27
|
-
xp: 0,
|
|
28
|
-
level: 1,
|
|
29
|
-
hatchedAt: Date.now(),
|
|
30
|
-
lastFed: Date.now(),
|
|
31
|
-
lastPlayed: Date.now(),
|
|
32
|
-
lastTick: Date.now(),
|
|
33
|
-
// ephemeral, not persisted as state-of-truth
|
|
34
|
-
state: "idle",
|
|
35
|
-
stateUntil: 0,
|
|
36
|
-
stateReason: "",
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
const CLAMP = (n, lo = 0, hi = 100) => Math.max(lo, Math.min(hi, n));
|
|
40
|
-
|
|
41
|
-
export function tick(state, now = Date.now()) {
|
|
42
|
-
const minutes = (now - state.lastTick) / 60_000;
|
|
43
|
-
if (minutes <= 0) return state;
|
|
44
|
-
|
|
45
|
-
const next = {
|
|
46
|
-
...state,
|
|
47
|
-
hunger: CLAMP(state.hunger - 0.5 * minutes),
|
|
48
|
-
happiness: CLAMP(state.happiness - 0.3 * minutes),
|
|
49
|
-
energy: CLAMP(state.energy - 0.2 * minutes),
|
|
50
|
-
lastTick: now,
|
|
51
|
-
};
|
|
52
|
-
return next;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function feed(state) {
|
|
56
|
-
return {
|
|
57
|
-
...state,
|
|
58
|
-
hunger: CLAMP(state.hunger + 25),
|
|
59
|
-
energy: CLAMP(state.energy - 5),
|
|
60
|
-
lastFed: Date.now(),
|
|
61
|
-
lastTick: Date.now(),
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function play(state) {
|
|
66
|
-
return {
|
|
67
|
-
...state,
|
|
68
|
-
happiness: CLAMP(state.happiness + 20),
|
|
69
|
-
energy: CLAMP(state.energy - 10),
|
|
70
|
-
hunger: CLAMP(state.hunger - 5),
|
|
71
|
-
lastPlayed: Date.now(),
|
|
72
|
-
lastTick: Date.now(),
|
|
73
|
-
xp: state.xp + 5,
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
export function rest(state) {
|
|
78
|
-
return {
|
|
79
|
-
...state,
|
|
80
|
-
energy: CLAMP(state.energy + 30),
|
|
81
|
-
lastTick: Date.now(),
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function rename(state, name) {
|
|
86
|
-
return {
|
|
87
|
-
...state,
|
|
88
|
-
name: String(name).slice(0, 20),
|
|
89
|
-
lastTick: Date.now(),
|
|
90
|
-
};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
export function switchSpecies(state, species) {
|
|
94
|
-
return { ...state, species, lastTick: Date.now() };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function celebrate(state, durationMs = 4000) {
|
|
98
|
-
return {
|
|
99
|
-
...state,
|
|
100
|
-
happiness: CLAMP(state.happiness + 5),
|
|
101
|
-
state: "celebrating",
|
|
102
|
-
stateUntil: Date.now() + durationMs,
|
|
103
|
-
stateReason: "session idle",
|
|
104
|
-
};
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
export function scared(state, durationMs = 5000) {
|
|
108
|
-
return {
|
|
109
|
-
...state,
|
|
110
|
-
state: "scared",
|
|
111
|
-
stateUntil: Date.now() + durationMs,
|
|
112
|
-
stateReason: "session error",
|
|
113
|
-
};
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export function working(state) {
|
|
117
|
-
// Don't overwrite a stronger transient state
|
|
118
|
-
if (state.state === "celebrating" && Date.now() < state.stateUntil)
|
|
119
|
-
return state;
|
|
120
|
-
if (state.state === "scared" && Date.now() < state.stateUntil) return state;
|
|
121
|
-
return { ...state, state: "working", stateUntil: 0, stateReason: "" };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Derive state purely from attributes when no external override is active.
|
|
125
|
-
export function deriveState(state, now = Date.now()) {
|
|
126
|
-
if (state.state === "celebrating" && now < state.stateUntil) return state;
|
|
127
|
-
if (state.state === "scared" && now < state.stateUntil) return state;
|
|
128
|
-
|
|
129
|
-
if (state.energy < 20) {
|
|
130
|
-
return { ...state, state: "sleeping", stateReason: "low energy" };
|
|
131
|
-
}
|
|
132
|
-
if (state.hunger < 25) {
|
|
133
|
-
return { ...state, state: "scared", stateReason: "hungry", stateUntil: now + 30_000 };
|
|
134
|
-
}
|
|
135
|
-
if (state.state === "working") return state;
|
|
136
|
-
return { ...state, state: "idle", stateReason: "" };
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export function maybeLevelUp(state) {
|
|
140
|
-
const xpNeeded = state.level * 50;
|
|
141
|
-
if (state.xp < xpNeeded) return state;
|
|
142
|
-
return {
|
|
143
|
-
...state,
|
|
144
|
-
level: state.level + 1,
|
|
145
|
-
xp: state.xp - xpNeeded,
|
|
146
|
-
happiness: CLAMP(state.happiness + 10),
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export function describe(state) {
|
|
151
|
-
const mood =
|
|
152
|
-
state.happiness > 80
|
|
153
|
-
? "happy"
|
|
154
|
-
: state.happiness > 50
|
|
155
|
-
? "okay"
|
|
156
|
-
: state.happiness > 25
|
|
157
|
-
? "grumpy"
|
|
158
|
-
: "depressed";
|
|
159
|
-
const tummy =
|
|
160
|
-
state.hunger > 70
|
|
161
|
-
? "full"
|
|
162
|
-
: state.hunger > 40
|
|
163
|
-
? "peckish"
|
|
164
|
-
: state.hunger > 20
|
|
165
|
-
? "hungry"
|
|
166
|
-
: "starving";
|
|
167
|
-
return `${state.name} the ${state.species} | Lv ${state.level} | ${mood} | ${tummy} | hunger ${Math.floor(state.hunger)} happiness ${Math.floor(state.happiness)} energy ${Math.floor(state.energy)} | xp ${state.xp}/${state.level * 50}`;
|
|
168
|
-
}
|
package/src/index.js
DELETED
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
// Main runtime. Wires together state, persistence, TUI, tmux polling.
|
|
2
|
-
//
|
|
3
|
-
// Usage from bin/opencode-buddy.js:
|
|
4
|
-
// await runInteractive() // for `opencode-buddy start`
|
|
5
|
-
// await runCommand(args) // for all other subcommands
|
|
6
|
-
|
|
7
|
-
import * as persistence from "./persistence.js";
|
|
8
|
-
import { createTui } from "./tui.js";
|
|
9
|
-
import {
|
|
10
|
-
tick,
|
|
11
|
-
feed,
|
|
12
|
-
play,
|
|
13
|
-
rest,
|
|
14
|
-
rename,
|
|
15
|
-
switchSpecies,
|
|
16
|
-
celebrate,
|
|
17
|
-
scared,
|
|
18
|
-
working,
|
|
19
|
-
deriveState,
|
|
20
|
-
maybeLevelUp,
|
|
21
|
-
describe as describeState,
|
|
22
|
-
DEFAULT_STATE,
|
|
23
|
-
} from "./buddy/state.js";
|
|
24
|
-
import { SPECIES } from "./buddy/species.js";
|
|
25
|
-
import {
|
|
26
|
-
inTmux,
|
|
27
|
-
currentPaneId,
|
|
28
|
-
splitRight,
|
|
29
|
-
captureMainPane,
|
|
30
|
-
inferActivity,
|
|
31
|
-
killBuddyPane,
|
|
32
|
-
} from "./tmux.js";
|
|
33
|
-
|
|
34
|
-
const TICK_MS = 1000;
|
|
35
|
-
const SAVE_EVERY_MS = 5000;
|
|
36
|
-
|
|
37
|
-
const HINT = `keys: f feed · p play · r rename · s switch · z rest · n hatch · q quit`;
|
|
38
|
-
|
|
39
|
-
export async function loadOrInit() {
|
|
40
|
-
const existing = await persistence.load();
|
|
41
|
-
if (existing) return existing;
|
|
42
|
-
const fresh = { ...DEFAULT_STATE, lastTick: Date.now() };
|
|
43
|
-
await persistence.save(fresh);
|
|
44
|
-
return fresh;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
export async function runInteractive({ autoSplit = true } = {}) {
|
|
48
|
-
if (!inTmux()) {
|
|
49
|
-
throw new Error(
|
|
50
|
-
"opencode-buddy start must run inside a tmux session. " +
|
|
51
|
-
"Start tmux first, then run: opencode-buddy start",
|
|
52
|
-
);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let state = await loadOrInit();
|
|
56
|
-
let buddyPane = null;
|
|
57
|
-
if (autoSplit) {
|
|
58
|
-
// The `start` subcommand: split a side pane that runs `render`,
|
|
59
|
-
// then exit so this main-pane process goes back to the shell prompt.
|
|
60
|
-
// The render process owns the TUI; we just kick it off.
|
|
61
|
-
splitRight(
|
|
62
|
-
`cd ${process.cwd()} && exec ${process.execPath} ${process.argv[1]} render`,
|
|
63
|
-
28,
|
|
64
|
-
);
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const tui = createTui({
|
|
69
|
-
onKey: (key) => {
|
|
70
|
-
switch (key) {
|
|
71
|
-
case "f":
|
|
72
|
-
state = feed(state);
|
|
73
|
-
flash("yum!");
|
|
74
|
-
break;
|
|
75
|
-
case "p":
|
|
76
|
-
state = play(state);
|
|
77
|
-
flash("hehe!");
|
|
78
|
-
break;
|
|
79
|
-
case "z":
|
|
80
|
-
state = rest(state);
|
|
81
|
-
flash("zzz...");
|
|
82
|
-
break;
|
|
83
|
-
case "n":
|
|
84
|
-
state = { ...DEFAULT_STATE, lastTick: Date.now() };
|
|
85
|
-
flash("new buddy!");
|
|
86
|
-
break;
|
|
87
|
-
case "r": {
|
|
88
|
-
// simple prompt via stdout
|
|
89
|
-
process.stdout.write(`\x1b[2J\x1b[Hnew name: `);
|
|
90
|
-
let name = "";
|
|
91
|
-
const onData = (b) => {
|
|
92
|
-
const s = b.toString("utf8");
|
|
93
|
-
if (s === "\n" || s === "\r" || s === "\u0003") {
|
|
94
|
-
process.stdin.off("data", onData);
|
|
95
|
-
if (name) state = rename(state, name);
|
|
96
|
-
flash(name ? `hi ${name}!` : "ok");
|
|
97
|
-
} else if (s === "\u007f" || s === "\b") {
|
|
98
|
-
name = name.slice(0, -1);
|
|
99
|
-
} else {
|
|
100
|
-
name += s.replace(/[\r\n]/g, "");
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
process.stdin.on("data", onData);
|
|
104
|
-
break;
|
|
105
|
-
}
|
|
106
|
-
case "s": {
|
|
107
|
-
// cycle species
|
|
108
|
-
const idx = SPECIES.indexOf(state.species);
|
|
109
|
-
const next = SPECIES[(idx + 1) % SPECIES.length];
|
|
110
|
-
state = switchSpecies(state, next);
|
|
111
|
-
flash(`now ${next}`);
|
|
112
|
-
break;
|
|
113
|
-
}
|
|
114
|
-
case "q":
|
|
115
|
-
case "ctrl-c":
|
|
116
|
-
cleanup();
|
|
117
|
-
process.exit(0);
|
|
118
|
-
break;
|
|
119
|
-
default:
|
|
120
|
-
// ignore
|
|
121
|
-
break;
|
|
122
|
-
}
|
|
123
|
-
},
|
|
124
|
-
});
|
|
125
|
-
|
|
126
|
-
let flashUntil = 0;
|
|
127
|
-
let flashText = "";
|
|
128
|
-
function flash(text, ms = 1500) {
|
|
129
|
-
flashText = text;
|
|
130
|
-
flashUntil = Date.now() + ms;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function buildHint() {
|
|
134
|
-
if (Date.now() < flashUntil) return `${flashText} · ${HINT}`;
|
|
135
|
-
return HINT;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
let lastActivity = "unknown";
|
|
139
|
-
let lastPoll = 0;
|
|
140
|
-
let lastSave = 0;
|
|
141
|
-
let lastReload = 0;
|
|
142
|
-
let lastKnownMtime = 0;
|
|
143
|
-
let running = true;
|
|
144
|
-
|
|
145
|
-
async function loop() {
|
|
146
|
-
while (running) {
|
|
147
|
-
const now = Date.now();
|
|
148
|
-
|
|
149
|
-
// 0. Reload from disk every 1.5s so the side pane picks up
|
|
150
|
-
// changes made by other processes (opencode plugin, headless
|
|
151
|
-
// CLI invocations from the main pane, etc.). Use mtime to
|
|
152
|
-
// detect external writes — lastTick is not a reliable
|
|
153
|
-
// signal because tick() rewrites it every loop.
|
|
154
|
-
if (now - lastReload > 1500) {
|
|
155
|
-
lastReload = now;
|
|
156
|
-
const mt = await persistence.mtime();
|
|
157
|
-
if (mt > 0 && mt !== lastKnownMtime) {
|
|
158
|
-
lastKnownMtime = mt;
|
|
159
|
-
const onDisk = await persistence.load();
|
|
160
|
-
if (onDisk) {
|
|
161
|
-
// Preserve in-memory transient state overrides
|
|
162
|
-
const transient = {
|
|
163
|
-
state: state.state,
|
|
164
|
-
stateUntil: state.stateUntil,
|
|
165
|
-
stateReason: state.stateReason,
|
|
166
|
-
};
|
|
167
|
-
state = { ...onDisk, ...transient };
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// 1. tick attributes for time decay
|
|
173
|
-
state = tick(state, now);
|
|
174
|
-
|
|
175
|
-
// 2. capture tmux main pane every 2s for activity inference
|
|
176
|
-
if (now - lastPoll > 2000) {
|
|
177
|
-
lastPoll = now;
|
|
178
|
-
const text = captureMainPane(60);
|
|
179
|
-
const activity = inferActivity(text);
|
|
180
|
-
if (activity !== lastActivity) {
|
|
181
|
-
lastActivity = activity;
|
|
182
|
-
if (activity === "working") state = working(state);
|
|
183
|
-
else if (activity === "error") state = scared(state);
|
|
184
|
-
else if (activity === "idle")
|
|
185
|
-
state = celebrate(state, 1500);
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// 3. derive final state (transient overrides first, then attributes)
|
|
190
|
-
state = deriveState(state, now);
|
|
191
|
-
|
|
192
|
-
// 4. level up check
|
|
193
|
-
state = maybeLevelUp(state);
|
|
194
|
-
|
|
195
|
-
// 5. render
|
|
196
|
-
tui.render(state, buildHint());
|
|
197
|
-
|
|
198
|
-
// 6. persist periodically
|
|
199
|
-
if (now - lastSave > SAVE_EVERY_MS) {
|
|
200
|
-
lastSave = now;
|
|
201
|
-
await persistence.save(state).catch(() => {});
|
|
202
|
-
lastKnownMtime = await persistence.mtime();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
await new Promise((r) => setTimeout(r, TICK_MS));
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function cleanup() {
|
|
210
|
-
running = false;
|
|
211
|
-
tui.stop();
|
|
212
|
-
if (buddyPane) killBuddyPane(buddyPane);
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
process.on("SIGINT", () => {
|
|
216
|
-
cleanup();
|
|
217
|
-
process.exit(0);
|
|
218
|
-
});
|
|
219
|
-
process.on("SIGTERM", () => {
|
|
220
|
-
cleanup();
|
|
221
|
-
process.exit(0);
|
|
222
|
-
});
|
|
223
|
-
|
|
224
|
-
tui.start();
|
|
225
|
-
await loop();
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
// Headless command runner: feed, play, rename, switch, hatch, stats, notify
|
|
229
|
-
export async function runCommand(argv) {
|
|
230
|
-
const [cmd, ...rest] = argv;
|
|
231
|
-
let state = await loadOrInit();
|
|
232
|
-
|
|
233
|
-
switch (cmd) {
|
|
234
|
-
case "hatch":
|
|
235
|
-
case "reset": {
|
|
236
|
-
const species = rest[0] && SPECIES.includes(rest[0]) ? rest[0] : "duck";
|
|
237
|
-
state = {
|
|
238
|
-
...DEFAULT_STATE,
|
|
239
|
-
species,
|
|
240
|
-
name: rest[1] || DEFAULT_STATE.name,
|
|
241
|
-
lastTick: Date.now(),
|
|
242
|
-
};
|
|
243
|
-
await persistence.save(state);
|
|
244
|
-
console.log(`Hatched a new ${state.species} named ${state.name}.`);
|
|
245
|
-
break;
|
|
246
|
-
}
|
|
247
|
-
case "feed":
|
|
248
|
-
state = feed(state);
|
|
249
|
-
await persistence.save(state);
|
|
250
|
-
console.log(`Fed ${state.name}. hunger -> ${Math.floor(state.hunger)}`);
|
|
251
|
-
break;
|
|
252
|
-
case "play":
|
|
253
|
-
state = play(state);
|
|
254
|
-
await persistence.save(state);
|
|
255
|
-
console.log(
|
|
256
|
-
`Played with ${state.name}. happiness -> ${Math.floor(state.happiness)}`,
|
|
257
|
-
);
|
|
258
|
-
break;
|
|
259
|
-
case "rest":
|
|
260
|
-
state = rest(state);
|
|
261
|
-
await persistence.save(state);
|
|
262
|
-
console.log(`${state.name} took a nap.`);
|
|
263
|
-
break;
|
|
264
|
-
case "rename": {
|
|
265
|
-
const name = rest.join(" ").trim();
|
|
266
|
-
if (!name) {
|
|
267
|
-
console.error("usage: opencode-buddy rename <name>");
|
|
268
|
-
process.exit(2);
|
|
269
|
-
}
|
|
270
|
-
state = rename(state, name);
|
|
271
|
-
await persistence.save(state);
|
|
272
|
-
console.log(`Renamed to ${state.name}.`);
|
|
273
|
-
break;
|
|
274
|
-
}
|
|
275
|
-
case "switch": {
|
|
276
|
-
const species = rest[0];
|
|
277
|
-
if (!species || !SPECIES.includes(species)) {
|
|
278
|
-
console.error(`species must be one of: ${SPECIES.join(", ")}`);
|
|
279
|
-
process.exit(2);
|
|
280
|
-
}
|
|
281
|
-
state = switchSpecies(state, species);
|
|
282
|
-
await persistence.save(state);
|
|
283
|
-
console.log(`${state.name} transformed into a ${species}.`);
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
case "stats":
|
|
287
|
-
console.log(describeState(state));
|
|
288
|
-
break;
|
|
289
|
-
case "notify": {
|
|
290
|
-
const event = rest[0];
|
|
291
|
-
if (event === "working" || event === "start")
|
|
292
|
-
state = working(state);
|
|
293
|
-
else if (event === "done" || event === "celebrate")
|
|
294
|
-
state = celebrate(state);
|
|
295
|
-
else if (event === "error" || event === "scared")
|
|
296
|
-
state = scared(state);
|
|
297
|
-
else {
|
|
298
|
-
console.error(
|
|
299
|
-
"usage: opencode-buddy notify <working|done|error>",
|
|
300
|
-
);
|
|
301
|
-
process.exit(2);
|
|
302
|
-
}
|
|
303
|
-
await persistence.save(state);
|
|
304
|
-
console.log(`Notified buddy: ${event}`);
|
|
305
|
-
break;
|
|
306
|
-
}
|
|
307
|
-
case "path":
|
|
308
|
-
console.log(persistence.pathForDisplay());
|
|
309
|
-
break;
|
|
310
|
-
default:
|
|
311
|
-
printUsage();
|
|
312
|
-
process.exit(cmd ? 2 : 0);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
function printUsage() {
|
|
317
|
-
console.log(`opencode-buddy — a virtual pet for your tmux session
|
|
318
|
-
|
|
319
|
-
Usage:
|
|
320
|
-
opencode-buddy start Launch interactive buddy in a tmux side pane
|
|
321
|
-
opencode-buddy hatch [species] [name] Start a brand new buddy (resets state)
|
|
322
|
-
opencode-buddy feed Feed your buddy
|
|
323
|
-
opencode-buddy play Play with your buddy
|
|
324
|
-
opencode-buddy rest Let your buddy rest
|
|
325
|
-
opencode-buddy rename <name> Give your buddy a new name
|
|
326
|
-
opencode-buddy switch <species> Change species (${SPECIES.join(", ")})
|
|
327
|
-
opencode-buddy notify <event> Notify buddy of an opencode event
|
|
328
|
-
(working | done | error)
|
|
329
|
-
opencode-buddy stats Print current buddy stats
|
|
330
|
-
opencode-buddy path Print path to state file
|
|
331
|
-
`);
|
|
332
|
-
}
|
package/src/install.js
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
// One-shot installer. Writes the /buddy slash command markdown file
|
|
2
|
-
// and adds this package to opencode.json's plugin list so opencode will
|
|
3
|
-
// bun-install it on the next startup.
|
|
4
|
-
//
|
|
5
|
-
// Re-runnable: safely no-ops if everything is already in place.
|
|
6
|
-
|
|
7
|
-
import { promises as fs } from "node:fs";
|
|
8
|
-
import path from "node:path";
|
|
9
|
-
import os from "node:os";
|
|
10
|
-
|
|
11
|
-
const OPENCODE_DIR = path.join(os.homedir(), ".config", "opencode");
|
|
12
|
-
const COMMANDS_DIR = path.join(OPENCODE_DIR, "commands");
|
|
13
|
-
const COMMAND_FILE = path.join(COMMANDS_DIR, "buddy.md");
|
|
14
|
-
const CONFIG_FILE = path.join(OPENCODE_DIR, "opencode.json");
|
|
15
|
-
|
|
16
|
-
const PLUGIN_NAME = "opencode-buddy";
|
|
17
|
-
|
|
18
|
-
const COMMAND_BODY = `---
|
|
19
|
-
description: Interact with your virtual buddy companion.
|
|
20
|
-
---
|
|
21
|
-
|
|
22
|
-
The user typed \`/buddy\`. Use the \`buddy\` tool to respond.
|
|
23
|
-
|
|
24
|
-
If the user typed \`/buddy\` with no arguments, call the tool with
|
|
25
|
-
\`action: "status"\`. If they passed sub-arguments like \`/buddy feed\`
|
|
26
|
-
or \`/buddy switch dragon\`, map them to the tool's \`action\`,
|
|
27
|
-
\`species\`, and \`name\` arguments accordingly. Examples:
|
|
28
|
-
|
|
29
|
-
/buddy -> buddy({ action: "status" })
|
|
30
|
-
/buddy feed -> buddy({ action: "feed" })
|
|
31
|
-
/buddy play -> buddy({ action: "play" })
|
|
32
|
-
/buddy switch cat -> buddy({ action: "switch", species: "cat" })
|
|
33
|
-
/buddy rename Mia -> buddy({ action: "rename", name: "Mia" })
|
|
34
|
-
/buddy hatch dragon Sparky -> buddy({ action: "hatch", species: "dragon", name: "Sparky" })
|
|
35
|
-
/buddy ascii -> buddy({ action: "ascii" })
|
|
36
|
-
|
|
37
|
-
Always show the tool's returned text to the user. If the user just wants
|
|
38
|
-
to see the buddy, call \`action: "ascii"\` to render the current art in a
|
|
39
|
-
code block.
|
|
40
|
-
`;
|
|
41
|
-
|
|
42
|
-
export async function install() {
|
|
43
|
-
const steps = [];
|
|
44
|
-
const errors = [];
|
|
45
|
-
|
|
46
|
-
// 1. Write the slash command file
|
|
47
|
-
try {
|
|
48
|
-
await fs.mkdir(COMMANDS_DIR, { recursive: true });
|
|
49
|
-
const existing = await fs.readFile(COMMAND_FILE, "utf8").catch(() => null);
|
|
50
|
-
if (existing === COMMAND_BODY) {
|
|
51
|
-
steps.push(`= ${COMMAND_FILE} (unchanged)`);
|
|
52
|
-
} else {
|
|
53
|
-
await fs.writeFile(COMMAND_FILE, COMMAND_BODY);
|
|
54
|
-
steps.push(`+ ${COMMAND_FILE}`);
|
|
55
|
-
}
|
|
56
|
-
} catch (err) {
|
|
57
|
-
errors.push(`failed to write ${COMMAND_FILE}: ${err.message}`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// 2. Patch opencode.json
|
|
61
|
-
try {
|
|
62
|
-
let config = {};
|
|
63
|
-
try {
|
|
64
|
-
const raw = await fs.readFile(CONFIG_FILE, "utf8");
|
|
65
|
-
config = JSON.parse(raw);
|
|
66
|
-
} catch (err) {
|
|
67
|
-
if (err.code !== "ENOENT") throw err;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
let pluginList = config.plugin;
|
|
71
|
-
if (pluginList == null) {
|
|
72
|
-
pluginList = [];
|
|
73
|
-
config.plugin = pluginList;
|
|
74
|
-
} else if (typeof pluginList === "string") {
|
|
75
|
-
pluginList = [pluginList];
|
|
76
|
-
config.plugin = pluginList;
|
|
77
|
-
} else if (!Array.isArray(pluginList)) {
|
|
78
|
-
errors.push(
|
|
79
|
-
`${CONFIG_FILE} has an unexpected "plugin" field type; expected array.`,
|
|
80
|
-
);
|
|
81
|
-
pluginList = [];
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (!pluginList.includes(PLUGIN_NAME)) {
|
|
85
|
-
pluginList.push(PLUGIN_NAME);
|
|
86
|
-
config.plugin = pluginList;
|
|
87
|
-
const tmp = CONFIG_FILE + ".tmp";
|
|
88
|
-
await fs.writeFile(tmp, JSON.stringify(config, null, 2));
|
|
89
|
-
await fs.rename(tmp, CONFIG_FILE);
|
|
90
|
-
steps.push(`~ ${CONFIG_FILE} (added "${PLUGIN_NAME}")`);
|
|
91
|
-
} else {
|
|
92
|
-
steps.push(`= ${CONFIG_FILE} (already lists "${PLUGIN_NAME}")`);
|
|
93
|
-
}
|
|
94
|
-
} catch (err) {
|
|
95
|
-
errors.push(`failed to patch ${CONFIG_FILE}: ${err.message}`);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 3. Report
|
|
99
|
-
console.log("opencode-buddy install");
|
|
100
|
-
console.log("------------------------");
|
|
101
|
-
for (const s of steps) console.log(` ${s}`);
|
|
102
|
-
if (errors.length) {
|
|
103
|
-
console.log("\nErrors:");
|
|
104
|
-
for (const e of errors) console.log(` ! ${e}`);
|
|
105
|
-
process.exit(1);
|
|
106
|
-
}
|
|
107
|
-
console.log("\nDone. Restart opencode to activate the /buddy command.");
|
|
108
|
-
console.log("Then in opencode, try: /buddy");
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
export async function uninstall() {
|
|
112
|
-
const steps = [];
|
|
113
|
-
const errors = [];
|
|
114
|
-
|
|
115
|
-
// 1. Remove the command file
|
|
116
|
-
try {
|
|
117
|
-
await fs.unlink(COMMAND_FILE);
|
|
118
|
-
steps.push(`- ${COMMAND_FILE}`);
|
|
119
|
-
} catch (err) {
|
|
120
|
-
if (err.code === "ENOENT") steps.push(`= ${COMMAND_FILE} (not present)`);
|
|
121
|
-
else errors.push(`failed to remove ${COMMAND_FILE}: ${err.message}`);
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// 2. Remove from opencode.json
|
|
125
|
-
try {
|
|
126
|
-
const raw = await fs.readFile(CONFIG_FILE, "utf8");
|
|
127
|
-
const config = JSON.parse(raw);
|
|
128
|
-
if (Array.isArray(config.plugin)) {
|
|
129
|
-
const before = config.plugin.length;
|
|
130
|
-
config.plugin = config.plugin.filter((p) => p !== PLUGIN_NAME);
|
|
131
|
-
if (config.plugin.length === 0) delete config.plugin;
|
|
132
|
-
if (config.plugin.length !== before) {
|
|
133
|
-
const tmp = CONFIG_FILE + ".tmp";
|
|
134
|
-
await fs.writeFile(tmp, JSON.stringify(config, null, 2));
|
|
135
|
-
await fs.rename(tmp, CONFIG_FILE);
|
|
136
|
-
steps.push(`~ ${CONFIG_FILE} (removed "${PLUGIN_NAME}")`);
|
|
137
|
-
} else {
|
|
138
|
-
steps.push(`= ${CONFIG_FILE} (no entry to remove)`);
|
|
139
|
-
}
|
|
140
|
-
} else {
|
|
141
|
-
steps.push(`= ${CONFIG_FILE} (no plugin array)`);
|
|
142
|
-
}
|
|
143
|
-
} catch (err) {
|
|
144
|
-
if (err.code === "ENOENT") steps.push(`= ${CONFIG_FILE} (not present)`);
|
|
145
|
-
else errors.push(`failed to patch ${CONFIG_FILE}: ${err.message}`);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
console.log("opencode-buddy uninstall");
|
|
149
|
-
console.log("--------------------------");
|
|
150
|
-
for (const s of steps) console.log(` ${s}`);
|
|
151
|
-
if (errors.length) {
|
|
152
|
-
console.log("\nErrors:");
|
|
153
|
-
for (const e of errors) console.log(` ! ${e}`);
|
|
154
|
-
process.exit(1);
|
|
155
|
-
}
|
|
156
|
-
console.log("\nDone. Restart opencode to deactivate the /buddy command.");
|
|
157
|
-
}
|