@umang-boss/claudemon 1.0.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 +164 -0
- package/bin/claudemon.js +52 -0
- package/bunfig.toml +2 -0
- package/cli/doctor.ts +334 -0
- package/cli/index.ts +42 -0
- package/cli/install.ts +248 -0
- package/cli/shared.ts +102 -0
- package/cli/uninstall.ts +155 -0
- package/cli/update.ts +318 -0
- package/hooks/post-tool-use.sh +127 -0
- package/hooks/stop.sh +49 -0
- package/hooks/user-prompt-submit.sh +73 -0
- package/package.json +68 -0
- package/scripts/download-colorscripts.ts +311 -0
- package/skills/buddy/SKILL.md +47 -0
- package/sprites/colorscripts/small/1-bulbasaur.txt +11 -0
- package/sprites/colorscripts/small/10-caterpie.txt +9 -0
- package/sprites/colorscripts/small/100-voltorb.txt +8 -0
- package/sprites/colorscripts/small/101-electrode.txt +9 -0
- package/sprites/colorscripts/small/102-exeggcute.txt +10 -0
- package/sprites/colorscripts/small/103-exeggutor.txt +23 -0
- package/sprites/colorscripts/small/104-cubone.txt +11 -0
- package/sprites/colorscripts/small/105-marowak.txt +16 -0
- package/sprites/colorscripts/small/106-hitmonlee.txt +16 -0
- package/sprites/colorscripts/small/107-hitmonchan.txt +19 -0
- package/sprites/colorscripts/small/108-lickitung.txt +10 -0
- package/sprites/colorscripts/small/109-koffing.txt +14 -0
- package/sprites/colorscripts/small/11-metapod.txt +10 -0
- package/sprites/colorscripts/small/110-weezing.txt +23 -0
- package/sprites/colorscripts/small/111-rhyhorn.txt +11 -0
- package/sprites/colorscripts/small/112-rhydon.txt +20 -0
- package/sprites/colorscripts/small/113-chansey.txt +11 -0
- package/sprites/colorscripts/small/114-tangela.txt +10 -0
- package/sprites/colorscripts/small/115-kangaskhan.txt +18 -0
- package/sprites/colorscripts/small/116-horsea.txt +10 -0
- package/sprites/colorscripts/small/117-seadra.txt +11 -0
- package/sprites/colorscripts/small/118-goldeen.txt +11 -0
- package/sprites/colorscripts/small/119-seaking.txt +16 -0
- package/sprites/colorscripts/small/12-butterfree.txt +20 -0
- package/sprites/colorscripts/small/120-staryu.txt +10 -0
- package/sprites/colorscripts/small/121-starmie.txt +17 -0
- package/sprites/colorscripts/small/122-mr-mime.txt +18 -0
- package/sprites/colorscripts/small/123-scyther.txt +21 -0
- package/sprites/colorscripts/small/124-jynx.txt +18 -0
- package/sprites/colorscripts/small/125-electabuzz.txt +19 -0
- package/sprites/colorscripts/small/126-magmar.txt +19 -0
- package/sprites/colorscripts/small/127-pinsir.txt +19 -0
- package/sprites/colorscripts/small/128-tauros.txt +20 -0
- package/sprites/colorscripts/small/129-magikarp.txt +13 -0
- package/sprites/colorscripts/small/13-weedle.txt +10 -0
- package/sprites/colorscripts/small/130-gyarados.txt +21 -0
- package/sprites/colorscripts/small/131-lapras.txt +19 -0
- package/sprites/colorscripts/small/132-ditto.txt +8 -0
- package/sprites/colorscripts/small/133-eevee.txt +10 -0
- package/sprites/colorscripts/small/134-vaporeon.txt +16 -0
- package/sprites/colorscripts/small/135-jolteon.txt +17 -0
- package/sprites/colorscripts/small/136-flareon.txt +18 -0
- package/sprites/colorscripts/small/137-porygon.txt +10 -0
- package/sprites/colorscripts/small/138-omanyte.txt +10 -0
- package/sprites/colorscripts/small/139-omastar.txt +18 -0
- package/sprites/colorscripts/small/14-kakuna.txt +10 -0
- package/sprites/colorscripts/small/140-kabuto.txt +8 -0
- package/sprites/colorscripts/small/141-kabutops.txt +17 -0
- package/sprites/colorscripts/small/142-aerodactyl.txt +17 -0
- package/sprites/colorscripts/small/143-snorlax.txt +21 -0
- package/sprites/colorscripts/small/144-articuno.txt +24 -0
- package/sprites/colorscripts/small/145-zapdos.txt +20 -0
- package/sprites/colorscripts/small/146-moltres.txt +23 -0
- package/sprites/colorscripts/small/147-dratini.txt +10 -0
- package/sprites/colorscripts/small/148-dragonair.txt +12 -0
- package/sprites/colorscripts/small/149-dragonite.txt +21 -0
- package/sprites/colorscripts/small/15-beedrill.txt +13 -0
- package/sprites/colorscripts/small/150-mewtwo.txt +22 -0
- package/sprites/colorscripts/small/151-mew.txt +14 -0
- package/sprites/colorscripts/small/16-pidgey.txt +10 -0
- package/sprites/colorscripts/small/17-pidgeotto.txt +11 -0
- package/sprites/colorscripts/small/18-pidgeot.txt +18 -0
- package/sprites/colorscripts/small/19-rattata.txt +12 -0
- package/sprites/colorscripts/small/2-ivysaur.txt +11 -0
- package/sprites/colorscripts/small/20-raticate.txt +12 -0
- package/sprites/colorscripts/small/21-spearow.txt +9 -0
- package/sprites/colorscripts/small/22-fearow.txt +12 -0
- package/sprites/colorscripts/small/23-ekans.txt +12 -0
- package/sprites/colorscripts/small/24-arbok.txt +16 -0
- package/sprites/colorscripts/small/25-pikachu.txt +11 -0
- package/sprites/colorscripts/small/26-raichu.txt +19 -0
- package/sprites/colorscripts/small/27-sandshrew.txt +10 -0
- package/sprites/colorscripts/small/28-sandslash.txt +16 -0
- package/sprites/colorscripts/small/29-nidoran-f.txt +11 -0
- package/sprites/colorscripts/small/3-venusaur.txt +21 -0
- package/sprites/colorscripts/small/30-nidorina.txt +12 -0
- package/sprites/colorscripts/small/31-nidoqueen.txt +19 -0
- package/sprites/colorscripts/small/32-nidoran-m.txt +11 -0
- package/sprites/colorscripts/small/33-nidorino.txt +12 -0
- package/sprites/colorscripts/small/34-nidoking.txt +18 -0
- package/sprites/colorscripts/small/35-clefairy.txt +11 -0
- package/sprites/colorscripts/small/36-clefable.txt +17 -0
- package/sprites/colorscripts/small/37-vulpix.txt +11 -0
- package/sprites/colorscripts/small/38-ninetales.txt +18 -0
- package/sprites/colorscripts/small/39-jigglypuff.txt +11 -0
- package/sprites/colorscripts/small/4-charmander.txt +11 -0
- package/sprites/colorscripts/small/40-wigglytuff.txt +20 -0
- package/sprites/colorscripts/small/41-zubat.txt +11 -0
- package/sprites/colorscripts/small/42-golbat.txt +18 -0
- package/sprites/colorscripts/small/43-oddish.txt +11 -0
- package/sprites/colorscripts/small/44-gloom.txt +12 -0
- package/sprites/colorscripts/small/45-vileplume.txt +17 -0
- package/sprites/colorscripts/small/46-paras.txt +11 -0
- package/sprites/colorscripts/small/47-parasect.txt +12 -0
- package/sprites/colorscripts/small/48-venonat.txt +14 -0
- package/sprites/colorscripts/small/49-venomoth.txt +19 -0
- package/sprites/colorscripts/small/5-charmeleon.txt +13 -0
- package/sprites/colorscripts/small/50-diglett.txt +8 -0
- package/sprites/colorscripts/small/51-dugtrio.txt +18 -0
- package/sprites/colorscripts/small/52-meowth.txt +12 -0
- package/sprites/colorscripts/small/53-persian.txt +20 -0
- package/sprites/colorscripts/small/54-psyduck.txt +12 -0
- package/sprites/colorscripts/small/55-golduck.txt +17 -0
- package/sprites/colorscripts/small/56-mankey.txt +11 -0
- package/sprites/colorscripts/small/57-primeape.txt +13 -0
- package/sprites/colorscripts/small/58-growlithe.txt +12 -0
- package/sprites/colorscripts/small/59-arcanine.txt +20 -0
- package/sprites/colorscripts/small/6-charizard.txt +21 -0
- package/sprites/colorscripts/small/60-poliwag.txt +9 -0
- package/sprites/colorscripts/small/61-poliwhirl.txt +11 -0
- package/sprites/colorscripts/small/62-poliwrath.txt +17 -0
- package/sprites/colorscripts/small/63-abra.txt +12 -0
- package/sprites/colorscripts/small/64-kadabra.txt +14 -0
- package/sprites/colorscripts/small/65-alakazam.txt +19 -0
- package/sprites/colorscripts/small/66-machop.txt +11 -0
- package/sprites/colorscripts/small/67-machoke.txt +12 -0
- package/sprites/colorscripts/small/68-machamp.txt +19 -0
- package/sprites/colorscripts/small/69-bellsprout.txt +9 -0
- package/sprites/colorscripts/small/7-squirtle.txt +10 -0
- package/sprites/colorscripts/small/70-weepinbell.txt +11 -0
- package/sprites/colorscripts/small/71-victreebel.txt +17 -0
- package/sprites/colorscripts/small/72-tentacool.txt +12 -0
- package/sprites/colorscripts/small/73-tentacruel.txt +20 -0
- package/sprites/colorscripts/small/74-geodude.txt +9 -0
- package/sprites/colorscripts/small/75-graveler.txt +12 -0
- package/sprites/colorscripts/small/76-golem.txt +18 -0
- package/sprites/colorscripts/small/77-ponyta.txt +13 -0
- package/sprites/colorscripts/small/78-rapidash.txt +18 -0
- package/sprites/colorscripts/small/79-slowpoke.txt +12 -0
- package/sprites/colorscripts/small/8-wartortle.txt +12 -0
- package/sprites/colorscripts/small/80-slowbro.txt +18 -0
- package/sprites/colorscripts/small/81-magnemite.txt +9 -0
- package/sprites/colorscripts/small/82-magneton.txt +18 -0
- package/sprites/colorscripts/small/83-farfetchd.txt +12 -0
- package/sprites/colorscripts/small/84-doduo.txt +10 -0
- package/sprites/colorscripts/small/85-dodrio.txt +17 -0
- package/sprites/colorscripts/small/86-seel.txt +13 -0
- package/sprites/colorscripts/small/87-dewgong.txt +20 -0
- package/sprites/colorscripts/small/88-grimer.txt +10 -0
- package/sprites/colorscripts/small/89-muk.txt +14 -0
- package/sprites/colorscripts/small/9-blastoise.txt +20 -0
- package/sprites/colorscripts/small/90-shellder.txt +10 -0
- package/sprites/colorscripts/small/91-cloyster.txt +18 -0
- package/sprites/colorscripts/small/92-gastly.txt +12 -0
- package/sprites/colorscripts/small/93-haunter.txt +14 -0
- package/sprites/colorscripts/small/94-gengar.txt +19 -0
- package/sprites/colorscripts/small/95-onix.txt +22 -0
- package/sprites/colorscripts/small/96-drowzee.txt +12 -0
- package/sprites/colorscripts/small/97-hypno.txt +19 -0
- package/sprites/colorscripts/small/98-krabby.txt +12 -0
- package/sprites/colorscripts/small/99-kingler.txt +20 -0
- package/src/engine/constants.ts +121 -0
- package/src/engine/encounter-pool.ts +71 -0
- package/src/engine/encounters.ts +308 -0
- package/src/engine/evolution-data.ts +535 -0
- package/src/engine/evolution.ts +310 -0
- package/src/engine/pokemon-data.ts +1838 -0
- package/src/engine/reactions.ts +877 -0
- package/src/engine/starter-pool.ts +47 -0
- package/src/engine/stats.ts +97 -0
- package/src/engine/types.ts +312 -0
- package/src/engine/xp.ts +135 -0
- package/src/gamification/achievements.ts +204 -0
- package/src/gamification/legendary-quests.ts +265 -0
- package/src/gamification/milestones.ts +86 -0
- package/src/hooks/award-xp.ts +131 -0
- package/src/hooks/increment-counter.ts +27 -0
- package/src/server/index.ts +78 -0
- package/src/server/instructions.ts +194 -0
- package/src/server/tools/achievements.ts +118 -0
- package/src/server/tools/catch.ts +295 -0
- package/src/server/tools/display-helpers.ts +35 -0
- package/src/server/tools/evolve.ts +236 -0
- package/src/server/tools/legendary.ts +78 -0
- package/src/server/tools/party.ts +251 -0
- package/src/server/tools/pet.ts +124 -0
- package/src/server/tools/pokedex.ts +286 -0
- package/src/server/tools/rename.ts +63 -0
- package/src/server/tools/show.ts +136 -0
- package/src/server/tools/starter.ts +175 -0
- package/src/server/tools/stats.ts +123 -0
- package/src/server/tools/visibility.ts +65 -0
- package/src/sprites/index.ts +45 -0
- package/src/state/io.ts +91 -0
- package/src/state/schemas.ts +131 -0
- package/src/state/state-manager.ts +321 -0
- package/statusline/buddy-status.sh +233 -0
- package/tsconfig.json +37 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_rename tool — Give your active Pokemon a nickname.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
|
|
8
|
+
import { StateManager } from "../../state/state-manager.js";
|
|
9
|
+
|
|
10
|
+
/** Register the buddy_rename tool. */
|
|
11
|
+
export function registerRenameTool(server: McpServer): void {
|
|
12
|
+
server.tool(
|
|
13
|
+
"buddy_rename",
|
|
14
|
+
"Give your active Pokemon a nickname (max 20 chars). Use empty string to reset to species name.",
|
|
15
|
+
{ name: z.string().max(20) },
|
|
16
|
+
async (params) => {
|
|
17
|
+
const stateManager = StateManager.getInstance();
|
|
18
|
+
const state = await stateManager.load();
|
|
19
|
+
|
|
20
|
+
if (!state || state.party.length === 0) {
|
|
21
|
+
return {
|
|
22
|
+
content: [{ type: "text" as const, text: "No Pokemon to rename! Pick a starter first." }],
|
|
23
|
+
isError: true,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const active = stateManager.getActivePokemon();
|
|
28
|
+
if (!active) {
|
|
29
|
+
return {
|
|
30
|
+
content: [{ type: "text" as const, text: "No active Pokemon found." }],
|
|
31
|
+
isError: true,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const species = POKEMON_BY_ID.get(active.pokemonId);
|
|
36
|
+
const speciesName = species?.name ?? "Pokemon";
|
|
37
|
+
const newName = params.name.trim();
|
|
38
|
+
|
|
39
|
+
if (newName === "") {
|
|
40
|
+
active.nickname = null;
|
|
41
|
+
await stateManager.save();
|
|
42
|
+
await stateManager.writeStatus();
|
|
43
|
+
return {
|
|
44
|
+
content: [
|
|
45
|
+
{
|
|
46
|
+
type: "text" as const,
|
|
47
|
+
text: `Nickname removed. Your buddy is back to ${speciesName}.`,
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const oldName = active.nickname ?? speciesName;
|
|
54
|
+
active.nickname = newName;
|
|
55
|
+
await stateManager.save();
|
|
56
|
+
await stateManager.writeStatus();
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
content: [{ type: "text" as const, text: `${oldName} is now known as **${newName}**!` }],
|
|
60
|
+
};
|
|
61
|
+
},
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_show tool — Display the active Pokemon with stats and XP.
|
|
3
|
+
* Supports "full" and "compact" display modes.
|
|
4
|
+
* Shows the pokemon-colorscript ANSI sprite art.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { z } from "zod";
|
|
8
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
9
|
+
import type { CodingStat } from "../../engine/types.js";
|
|
10
|
+
import { CODING_STATS } from "../../engine/types.js";
|
|
11
|
+
import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
|
|
12
|
+
import { STAT_DISPLAY_NAMES } from "../../engine/constants.js";
|
|
13
|
+
import { renderStatBar, getTrainerTitle } from "../../engine/stats.js";
|
|
14
|
+
import { xpProgressPercent, xpToNextLevel } from "../../engine/xp.js";
|
|
15
|
+
import { StateManager } from "../../state/state-manager.js";
|
|
16
|
+
import { formatTypes, renderXpBar, pad } from "./display-helpers.js";
|
|
17
|
+
|
|
18
|
+
/** Registers the buddy_show tool on the MCP server. */
|
|
19
|
+
export function registerShowTool(server: McpServer): void {
|
|
20
|
+
server.tool(
|
|
21
|
+
"buddy_show",
|
|
22
|
+
"Display your active Pokemon's status, stats, and XP progress.",
|
|
23
|
+
{ detail: z.enum(["full", "compact"]).optional() },
|
|
24
|
+
async (params) => {
|
|
25
|
+
const stateManager = StateManager.getInstance();
|
|
26
|
+
const state = await stateManager.load();
|
|
27
|
+
|
|
28
|
+
if (!state || state.party.length === 0) {
|
|
29
|
+
return {
|
|
30
|
+
content: [
|
|
31
|
+
{
|
|
32
|
+
type: "text" as const,
|
|
33
|
+
text: "You don't have a Pokemon yet! Use buddy_starter to pick your first partner.",
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
isError: true,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const active = stateManager.getActivePokemon();
|
|
41
|
+
if (!active) {
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: "text" as const,
|
|
46
|
+
text: "No active Pokemon found in your party. Something went wrong with your save data.",
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
isError: true,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const species = POKEMON_BY_ID.get(active.pokemonId);
|
|
54
|
+
if (!species) {
|
|
55
|
+
return {
|
|
56
|
+
content: [
|
|
57
|
+
{
|
|
58
|
+
type: "text" as const,
|
|
59
|
+
text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
|
|
60
|
+
},
|
|
61
|
+
],
|
|
62
|
+
isError: true,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const detail = params.detail ?? "full";
|
|
67
|
+
const percent = xpProgressPercent(active, species);
|
|
68
|
+
|
|
69
|
+
// Compact mode: single line
|
|
70
|
+
if (detail === "compact") {
|
|
71
|
+
const displayName = active.nickname ? `${active.nickname} (${species.name})` : species.name;
|
|
72
|
+
const xpBar = renderXpBar(percent, 15);
|
|
73
|
+
const text = `${displayName} Lv.${active.level} [${xpBar}] ${percent}% XP`;
|
|
74
|
+
return {
|
|
75
|
+
content: [{ type: "text" as const, text }],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Full mode: detailed card with sprite image
|
|
80
|
+
const displayName = active.nickname ? `${species.name} (${active.nickname})` : species.name;
|
|
81
|
+
|
|
82
|
+
const needed = xpToNextLevel(active.level, species.expGroup);
|
|
83
|
+
const title = getTrainerTitle(active.level);
|
|
84
|
+
const typeStr = formatTypes(species.types);
|
|
85
|
+
const personality = active.personality ?? species.description;
|
|
86
|
+
|
|
87
|
+
const W = 42;
|
|
88
|
+
const border = "\u2500".repeat(W);
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
|
|
91
|
+
lines.push(`\u250c${border}\u2510`);
|
|
92
|
+
lines.push(`\u2502 ${pad(`${displayName} Lv.${active.level}`, W - 2)}\u2502`);
|
|
93
|
+
lines.push(`\u2502 ${pad(typeStr, W - 2)}\u2502`);
|
|
94
|
+
lines.push(`\u2502${" ".repeat(W)}\u2502`);
|
|
95
|
+
lines.push(`\u2502 ${pad(`"${personality}"`, W - 2)}\u2502`);
|
|
96
|
+
lines.push(`\u2502${" ".repeat(W)}\u2502`);
|
|
97
|
+
|
|
98
|
+
// Coding stats
|
|
99
|
+
for (const stat of CODING_STATS) {
|
|
100
|
+
const value = active.codingStats[stat as CodingStat];
|
|
101
|
+
const bar = renderStatBar(value);
|
|
102
|
+
const label = STAT_DISPLAY_NAMES[stat as CodingStat].padEnd(10);
|
|
103
|
+
lines.push(`\u2502 ${pad(`${label} ${bar} ${String(value).padStart(3)}`, W - 2)}\u2502`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
lines.push(`\u2502${" ".repeat(W)}\u2502`);
|
|
107
|
+
|
|
108
|
+
// XP bar
|
|
109
|
+
const xpBar = renderXpBar(percent);
|
|
110
|
+
lines.push(`\u2502 ${pad(`XP: [${xpBar}] ${percent}%`, W - 2)}\u2502`);
|
|
111
|
+
|
|
112
|
+
if (needed > 0) {
|
|
113
|
+
lines.push(`\u2502 ${pad(`Next level: ${needed} XP needed`, W - 2)}\u2502`);
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(`\u2502 ${pad("MAX LEVEL", W - 2)}\u2502`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
lines.push(`\u2502${" ".repeat(W)}\u2502`);
|
|
119
|
+
lines.push(`\u2502 ${pad(`Trainer: ${state.trainerName} \u2014 ${title}`, W - 2)}\u2502`);
|
|
120
|
+
|
|
121
|
+
const streakDisplay =
|
|
122
|
+
state.streak.currentStreak > 0
|
|
123
|
+
? `Streak: ${state.streak.currentStreak} days`
|
|
124
|
+
: "Streak: 0 days";
|
|
125
|
+
lines.push(`\u2502 ${pad(streakDisplay, W - 2)}\u2502`);
|
|
126
|
+
lines.push(`\u2514${border}\u2518`);
|
|
127
|
+
|
|
128
|
+
// Prepend colorscript sprite if available
|
|
129
|
+
// ANSI colorscripts don't render in MCP text output — skip sprites here
|
|
130
|
+
// Users can view sprites in terminal: cat sprites/colorscripts/small/{id}-{name}.txt
|
|
131
|
+
return {
|
|
132
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_starter tool — First-run starter Pokemon selection.
|
|
3
|
+
* Presents 3 daily-deterministic Pokemon from the starter pool.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import type { OwnedPokemon, CodingStats } from "../../engine/types.js";
|
|
9
|
+
import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
|
|
10
|
+
import { STARTER_POOL } from "../../engine/starter-pool.js";
|
|
11
|
+
import { STARTER_LEVEL } from "../../engine/constants.js";
|
|
12
|
+
import { initCodingStats } from "../../engine/stats.js";
|
|
13
|
+
import { StateManager } from "../../state/state-manager.js";
|
|
14
|
+
import { formatTypes } from "./display-helpers.js";
|
|
15
|
+
|
|
16
|
+
/** Simple string hash for deterministic seeding. */
|
|
17
|
+
function hashString(str: string): number {
|
|
18
|
+
let hash = 0;
|
|
19
|
+
for (let i = 0; i < str.length; i++) {
|
|
20
|
+
const char = str.charCodeAt(i);
|
|
21
|
+
hash = ((hash << 5) - hash + char) | 0;
|
|
22
|
+
}
|
|
23
|
+
return Math.abs(hash);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Deterministic shuffle using a seed, returns a new array. */
|
|
27
|
+
function seededShuffle<T>(array: readonly T[], seed: number): T[] {
|
|
28
|
+
const result = [...array];
|
|
29
|
+
let s = seed;
|
|
30
|
+
for (let i = result.length - 1; i > 0; i--) {
|
|
31
|
+
// Simple LCG for deterministic pseudo-random
|
|
32
|
+
s = (s * 1664525 + 1013904223) & 0x7fffffff;
|
|
33
|
+
const j = s % (i + 1);
|
|
34
|
+
[result[i], result[j]] = [result[j]!, result[i]!];
|
|
35
|
+
}
|
|
36
|
+
return result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Get today's date string in YYYY-MM-DD for daily seed. */
|
|
40
|
+
function todayString(): string {
|
|
41
|
+
const now = new Date();
|
|
42
|
+
const year = now.getFullYear();
|
|
43
|
+
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
44
|
+
const day = String(now.getDate()).padStart(2, "0");
|
|
45
|
+
return `${year}-${month}-${day}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Generate today's 3 starter options (deterministic per day). */
|
|
49
|
+
function getDailyStarters(): number[] {
|
|
50
|
+
const seed = hashString(`claudemon-starter-${todayString()}`);
|
|
51
|
+
const shuffled = seededShuffle(STARTER_POOL, seed);
|
|
52
|
+
return [shuffled[0]!, shuffled[1]!, shuffled[2]!];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Generate a UUID v4. */
|
|
56
|
+
function generateUUID(): string {
|
|
57
|
+
return crypto.randomUUID();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Registers the buddy_starter tool on the MCP server. */
|
|
61
|
+
export function registerStarterTool(server: McpServer): void {
|
|
62
|
+
server.tool(
|
|
63
|
+
"buddy_starter",
|
|
64
|
+
"Pick your starter Pokemon! Call without a choice to see today's 3 options, or with choice (1, 2, or 3) to pick one.",
|
|
65
|
+
{ choice: z.number().min(1).max(3).optional() },
|
|
66
|
+
async (params) => {
|
|
67
|
+
const stateManager = StateManager.getInstance();
|
|
68
|
+
const existingState = await stateManager.load();
|
|
69
|
+
|
|
70
|
+
// Block if player already has Pokemon
|
|
71
|
+
if (existingState !== null && existingState.party.length > 0) {
|
|
72
|
+
return {
|
|
73
|
+
content: [
|
|
74
|
+
{
|
|
75
|
+
type: "text" as const,
|
|
76
|
+
text: "You already have a Pokemon partner! Use buddy_show to check on them.",
|
|
77
|
+
},
|
|
78
|
+
],
|
|
79
|
+
isError: true,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const starterIds = getDailyStarters();
|
|
84
|
+
|
|
85
|
+
// No choice provided — show the 3 options
|
|
86
|
+
if (params.choice === undefined) {
|
|
87
|
+
const lines: string[] = ["Welcome to Claudemon! Choose your coding companion:", ""];
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < 3; i++) {
|
|
90
|
+
const pokemonId = starterIds[i]!;
|
|
91
|
+
const species = POKEMON_BY_ID.get(pokemonId);
|
|
92
|
+
if (!species) continue;
|
|
93
|
+
|
|
94
|
+
const stats = initCodingStats(species.baseStats);
|
|
95
|
+
const topStat = (Object.entries(stats) as Array<[keyof CodingStats, number]>).sort(
|
|
96
|
+
(a, b) => b[1] - a[1],
|
|
97
|
+
)[0]!;
|
|
98
|
+
|
|
99
|
+
lines.push(
|
|
100
|
+
` ${i + 1}. **${species.name}** (${formatTypes(species.types)})`,
|
|
101
|
+
` "${species.description}"`,
|
|
102
|
+
` Best stat: ${topStat[0].toUpperCase()} (${topStat[1]})`,
|
|
103
|
+
"",
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
lines.push("Call buddy_starter with choice: 1, 2, or 3 to pick your partner!");
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Choice provided — create the starter
|
|
115
|
+
const chosenIndex = params.choice - 1;
|
|
116
|
+
const chosenId = starterIds[chosenIndex]!;
|
|
117
|
+
const species = POKEMON_BY_ID.get(chosenId);
|
|
118
|
+
|
|
119
|
+
if (!species) {
|
|
120
|
+
return {
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: "text" as const,
|
|
124
|
+
text: "Something went wrong looking up that Pokemon. Please try again.",
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
isError: true,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const starter: OwnedPokemon = {
|
|
132
|
+
id: generateUUID(),
|
|
133
|
+
pokemonId: species.id,
|
|
134
|
+
nickname: null,
|
|
135
|
+
level: STARTER_LEVEL,
|
|
136
|
+
currentXp: 0,
|
|
137
|
+
totalXp: 0,
|
|
138
|
+
codingStats: initCodingStats(species.baseStats),
|
|
139
|
+
happiness: 70,
|
|
140
|
+
caughtAt: new Date().toISOString(),
|
|
141
|
+
evolvedAt: null,
|
|
142
|
+
isActive: true,
|
|
143
|
+
personality: species.description,
|
|
144
|
+
shiny: false,
|
|
145
|
+
isStarter: true,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const trainerId = generateUUID();
|
|
149
|
+
const trainerName = "Trainer";
|
|
150
|
+
await stateManager.initializePlayer(trainerId, trainerName, starter);
|
|
151
|
+
await stateManager.writeStatus();
|
|
152
|
+
|
|
153
|
+
const lines: string[] = [];
|
|
154
|
+
|
|
155
|
+
lines.push(
|
|
156
|
+
`Congratulations! You chose **${species.name}**!`,
|
|
157
|
+
"",
|
|
158
|
+
` Species: ${species.name} (#${String(species.id).padStart(3, "0")})`,
|
|
159
|
+
` Type: ${formatTypes(species.types)}`,
|
|
160
|
+
` Level: ${STARTER_LEVEL}`,
|
|
161
|
+
` Exp Group: ${species.expGroup}`,
|
|
162
|
+
"",
|
|
163
|
+
` "${species.description}"`,
|
|
164
|
+
"",
|
|
165
|
+
`Your coding journey begins now. Write code, fix bugs, and watch ${species.name} grow!`,
|
|
166
|
+
"",
|
|
167
|
+
"Use buddy_show to see your Pokemon, or buddy_pet to bond with them.",
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return {
|
|
171
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
172
|
+
};
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_stats tool — Detailed stat breakdown for the active Pokemon.
|
|
3
|
+
* Shows coding stats, base values, activity bonuses, and XP details.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
7
|
+
import type { CodingStat } from "../../engine/types.js";
|
|
8
|
+
import { CODING_STATS } from "../../engine/types.js";
|
|
9
|
+
import { POKEMON_BY_ID } from "../../engine/pokemon-data.js";
|
|
10
|
+
import { STAT_DISPLAY_NAMES } from "../../engine/constants.js";
|
|
11
|
+
import { renderStatBar, calculateDisplayStat } from "../../engine/stats.js";
|
|
12
|
+
import { xpProgressPercent, xpToNextLevel, cumulativeXpForLevel } from "../../engine/xp.js";
|
|
13
|
+
import { StateManager } from "../../state/state-manager.js";
|
|
14
|
+
import { renderXpBar, CODING_TO_BASE } from "./display-helpers.js";
|
|
15
|
+
|
|
16
|
+
/** Registers the buddy_stats tool on the MCP server. */
|
|
17
|
+
export function registerStatsTool(server: McpServer): void {
|
|
18
|
+
server.tool(
|
|
19
|
+
"buddy_stats",
|
|
20
|
+
"Show a detailed stat breakdown for your active Pokemon, including base values, activity bonuses, and XP info.",
|
|
21
|
+
{},
|
|
22
|
+
async () => {
|
|
23
|
+
const stateManager = StateManager.getInstance();
|
|
24
|
+
const state = await stateManager.load();
|
|
25
|
+
|
|
26
|
+
if (!state || state.party.length === 0) {
|
|
27
|
+
return {
|
|
28
|
+
content: [
|
|
29
|
+
{
|
|
30
|
+
type: "text" as const,
|
|
31
|
+
text: "You don't have a Pokemon yet! Use buddy_starter to pick your first partner.",
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
isError: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const active = stateManager.getActivePokemon();
|
|
39
|
+
if (!active) {
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{
|
|
43
|
+
type: "text" as const,
|
|
44
|
+
text: "No active Pokemon found in your party.",
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const species = POKEMON_BY_ID.get(active.pokemonId);
|
|
52
|
+
if (!species) {
|
|
53
|
+
return {
|
|
54
|
+
content: [
|
|
55
|
+
{
|
|
56
|
+
type: "text" as const,
|
|
57
|
+
text: `Could not find species data for Pokemon ID ${active.pokemonId}.`,
|
|
58
|
+
},
|
|
59
|
+
],
|
|
60
|
+
isError: true,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lines: string[] = [];
|
|
65
|
+
|
|
66
|
+
lines.push(`== ${species.name} Lv.${active.level} -- Stat Breakdown ==`);
|
|
67
|
+
lines.push("");
|
|
68
|
+
|
|
69
|
+
// Column header
|
|
70
|
+
lines.push(" STAT BAR VALUE BASE BONUS");
|
|
71
|
+
lines.push(" " + "\u2500".repeat(48));
|
|
72
|
+
|
|
73
|
+
for (const stat of CODING_STATS) {
|
|
74
|
+
const codingStat = stat as CodingStat;
|
|
75
|
+
const baseKey = CODING_TO_BASE[codingStat];
|
|
76
|
+
const baseValue = species.baseStats[baseKey];
|
|
77
|
+
|
|
78
|
+
// The initial coding stat was floor(baseStat * 0.5).
|
|
79
|
+
// Current activity bonus = current codingStats value - initial value.
|
|
80
|
+
const initialValue = Math.floor(baseValue * 0.5);
|
|
81
|
+
const activityBonus = active.codingStats[codingStat] - initialValue;
|
|
82
|
+
|
|
83
|
+
const displayValue = calculateDisplayStat(baseValue, active.level, activityBonus);
|
|
84
|
+
const bar = renderStatBar(displayValue);
|
|
85
|
+
const label = STAT_DISPLAY_NAMES[codingStat].padEnd(10);
|
|
86
|
+
|
|
87
|
+
lines.push(
|
|
88
|
+
` ${label} ${bar} ${String(displayValue).padStart(4)}` +
|
|
89
|
+
` ${String(baseValue).padStart(3)}` +
|
|
90
|
+
` ${activityBonus >= 0 ? "+" : ""}${activityBonus}`,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
lines.push("");
|
|
95
|
+
lines.push(" " + "\u2500".repeat(48));
|
|
96
|
+
lines.push("");
|
|
97
|
+
|
|
98
|
+
// XP details
|
|
99
|
+
const percent = xpProgressPercent(active, species);
|
|
100
|
+
const needed = xpToNextLevel(active.level, species.expGroup);
|
|
101
|
+
const cumulativeNow = cumulativeXpForLevel(active.level, species.expGroup);
|
|
102
|
+
const xpBar = renderXpBar(percent);
|
|
103
|
+
|
|
104
|
+
lines.push(" XP Details:");
|
|
105
|
+
lines.push(` Exp Group: ${species.expGroup}`);
|
|
106
|
+
lines.push(` Level: ${active.level}`);
|
|
107
|
+
lines.push(` Current XP: ${active.currentXp} / ${needed > 0 ? needed : "MAX"}`);
|
|
108
|
+
lines.push(` Total XP: ${active.totalXp}`);
|
|
109
|
+
lines.push(` Cumulative: ${cumulativeNow} (to reach Lv.${active.level})`);
|
|
110
|
+
lines.push(` Progress: [${xpBar}] ${percent}%`);
|
|
111
|
+
|
|
112
|
+
if (needed > 0) {
|
|
113
|
+
lines.push(` To next level: ${needed - active.currentXp} XP remaining`);
|
|
114
|
+
} else {
|
|
115
|
+
lines.push(" MAX LEVEL REACHED");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* buddy_hide / buddy_show_sprite — Toggle sprite visibility in status line.
|
|
3
|
+
* Writes to ~/.claudemon/config.json which the status line script reads.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
8
|
+
import { getStateDir } from "../../engine/constants.js";
|
|
9
|
+
import { ensureDir, atomicWrite, safeRead } from "../../state/io.js";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
|
|
12
|
+
interface BuddyDisplayConfig {
|
|
13
|
+
spriteHidden: boolean;
|
|
14
|
+
[key: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function getConfigPath(): Promise<string> {
|
|
18
|
+
return join(getStateDir(), "config.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function readConfig(): Promise<BuddyDisplayConfig> {
|
|
22
|
+
const path = await getConfigPath();
|
|
23
|
+
const data = await safeRead<BuddyDisplayConfig>(path);
|
|
24
|
+
return data ?? { spriteHidden: false };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function writeConfig(config: BuddyDisplayConfig): Promise<void> {
|
|
28
|
+
const path = await getConfigPath();
|
|
29
|
+
await ensureDir(getStateDir());
|
|
30
|
+
await atomicWrite(path, JSON.stringify(config, null, 2));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Register buddy_hide tool — hides sprite from status line */
|
|
34
|
+
export function registerHideTool(server: McpServer): void {
|
|
35
|
+
server.tool("buddy_hide", "Hide the Pokemon sprite from the status line.", {}, async () => {
|
|
36
|
+
const config = await readConfig();
|
|
37
|
+
config.spriteHidden = true;
|
|
38
|
+
await writeConfig(config);
|
|
39
|
+
return {
|
|
40
|
+
content: [
|
|
41
|
+
{
|
|
42
|
+
type: "text" as const,
|
|
43
|
+
text: "Sprite hidden from status line. Use /buddy unhide to show it again.",
|
|
44
|
+
},
|
|
45
|
+
],
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Register buddy_unhide tool — shows sprite in status line */
|
|
51
|
+
export function registerUnhideTool(server: McpServer): void {
|
|
52
|
+
server.tool("buddy_unhide", "Show the Pokemon sprite in the status line.", {}, async () => {
|
|
53
|
+
const config = await readConfig();
|
|
54
|
+
config.spriteHidden = false;
|
|
55
|
+
await writeConfig(config);
|
|
56
|
+
return {
|
|
57
|
+
content: [
|
|
58
|
+
{
|
|
59
|
+
type: "text" as const,
|
|
60
|
+
text: "Sprite restored to status line!",
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sprite loader for pokemon-colorscripts.
|
|
3
|
+
* Reads pre-rendered ANSI art from small .txt files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { POKEMON_BY_ID } from "../engine/pokemon-data.js";
|
|
9
|
+
|
|
10
|
+
const COLORSCRIPT_DIR = join(import.meta.dir, "../../sprites/colorscripts/small");
|
|
11
|
+
|
|
12
|
+
const cache = new Map<number, string>();
|
|
13
|
+
|
|
14
|
+
/** Normalize Pokemon name to match colorscript filename convention */
|
|
15
|
+
function normalizeSpriteName(name: string): string {
|
|
16
|
+
return name
|
|
17
|
+
.toLowerCase()
|
|
18
|
+
.replace("♀", "-f")
|
|
19
|
+
.replace("♂", "-m")
|
|
20
|
+
.replace("'", "")
|
|
21
|
+
.replace(". ", "-")
|
|
22
|
+
.replace(" ", "-");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Load a small colorscript sprite (~11 lines). Used everywhere. */
|
|
26
|
+
export function loadSmallSprite(pokemonId: number): string | null {
|
|
27
|
+
const cached = cache.get(pokemonId);
|
|
28
|
+
if (cached !== undefined) return cached;
|
|
29
|
+
|
|
30
|
+
const pokemon = POKEMON_BY_ID.get(pokemonId);
|
|
31
|
+
if (!pokemon) return null;
|
|
32
|
+
|
|
33
|
+
const fileName = `${pokemonId}-${normalizeSpriteName(pokemon.name)}.txt`;
|
|
34
|
+
const filePath = join(COLORSCRIPT_DIR, fileName);
|
|
35
|
+
|
|
36
|
+
if (!existsSync(filePath)) return null;
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const content = readFileSync(filePath, "utf-8");
|
|
40
|
+
cache.set(pokemonId, content);
|
|
41
|
+
return content;
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/state/io.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file I/O helpers for state persistence.
|
|
3
|
+
* All disk writes go through atomicWrite to prevent corruption.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { rename, mkdir, copyFile, unlink, stat } from "node:fs/promises";
|
|
7
|
+
|
|
8
|
+
/** Write data atomically via temp file + rename */
|
|
9
|
+
export async function atomicWrite(path: string, data: string): Promise<void> {
|
|
10
|
+
const tmpPath = `${path}.${Date.now()}.tmp`;
|
|
11
|
+
await Bun.write(tmpPath, data);
|
|
12
|
+
await rename(tmpPath, path);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Read and parse JSON, returning null if file doesn't exist or contains invalid JSON */
|
|
16
|
+
export async function safeRead<T>(path: string): Promise<T | null> {
|
|
17
|
+
const file = Bun.file(path);
|
|
18
|
+
const exists = await file.exists();
|
|
19
|
+
if (!exists) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const text = await file.text();
|
|
24
|
+
if (!text.trim()) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return JSON.parse(text) as T;
|
|
28
|
+
} catch {
|
|
29
|
+
// Invalid JSON — return null so caller can handle corruption recovery
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Ensure directory exists, creating parents as needed */
|
|
35
|
+
export async function ensureDir(path: string): Promise<void> {
|
|
36
|
+
await mkdir(path, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Backup a corrupted file by copying it to `<path>.corrupt.<timestamp>`.
|
|
41
|
+
* Logs the backup path to stderr so the user knows where to find it.
|
|
42
|
+
*/
|
|
43
|
+
export async function backupCorrupted(path: string): Promise<string> {
|
|
44
|
+
const backupPath = `${path}.corrupt.${Date.now()}`;
|
|
45
|
+
try {
|
|
46
|
+
await copyFile(path, backupPath);
|
|
47
|
+
} catch {
|
|
48
|
+
// If copy fails (e.g., file vanished), nothing to back up
|
|
49
|
+
}
|
|
50
|
+
return backupPath;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Maximum age (ms) before a lock file is considered stale */
|
|
54
|
+
const LOCK_MAX_AGE_MS = 5000;
|
|
55
|
+
|
|
56
|
+
/** Retry delay (ms) when a fresh lock is detected */
|
|
57
|
+
const LOCK_RETRY_DELAY_MS = 100;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Execute a function while holding a simple file lock.
|
|
61
|
+
* Creates a `.lock` file before the operation and removes it after.
|
|
62
|
+
* If a lock file exists but is older than 5 seconds, it is treated as stale
|
|
63
|
+
* (left behind by a dead process) and overwritten.
|
|
64
|
+
*/
|
|
65
|
+
export async function withLock<T>(lockPath: string, fn: () => Promise<T>): Promise<T> {
|
|
66
|
+
// Check for existing lock
|
|
67
|
+
try {
|
|
68
|
+
const lockStat = await stat(lockPath);
|
|
69
|
+
const lockAge = Date.now() - lockStat.mtimeMs;
|
|
70
|
+
if (lockAge < LOCK_MAX_AGE_MS) {
|
|
71
|
+
// Lock is fresh — wait briefly then proceed (best-effort advisory lock)
|
|
72
|
+
await new Promise((r) => setTimeout(r, LOCK_RETRY_DELAY_MS));
|
|
73
|
+
}
|
|
74
|
+
// Stale lock or retry — proceed to overwrite
|
|
75
|
+
} catch {
|
|
76
|
+
// No lock file exists — proceed
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Acquire lock
|
|
80
|
+
await Bun.write(lockPath, String(Date.now()));
|
|
81
|
+
try {
|
|
82
|
+
return await fn();
|
|
83
|
+
} finally {
|
|
84
|
+
// Release lock
|
|
85
|
+
try {
|
|
86
|
+
await unlink(lockPath);
|
|
87
|
+
} catch {
|
|
88
|
+
// Ignore — lock may have been cleaned up by another process or doctor
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|