buddymon/lib/cli.py
pyr0ball 81712d06e3 fix: roster via output file, catalog path, already-owned spawn guard
- post-tool-use: find_catalog() checks ~/.claude/buddymon/catalog.json first
  so Veritarch is always found regardless of which cache CLAUDE_PLUGIN_ROOT
  points to; fixes 'your buddy' display bug
- post-tool-use: skip spawn when monster already in collection (was only
  checked on existing encounters, not at spawn time; caused already-caught
  monsters to re-announce)
- post-tool-use: fix spawn block indentation — set_active_encounter was
  outside the else, running even on already-owned skip
- user-prompt-submit: same null buddymon_id fallback fix as post-tool-use
- cli.py cmd_roster: buffer all output to StringIO; write roster_output.txt
  + roster_pending.txt; suppress stdout when piped (isatty check)
- roster-stop.py: read roster_output.txt directly instead of re-running cli.py
  (eliminated the recursive flag-write loop); remove unused subprocess import
2026-04-12 19:02:04 -07:00

998 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Buddymon CLI — all game logic lives here.
SKILL.md is a thin orchestrator that runs this script and handles
one round of user I/O when the script emits a marker:
[INPUT_NEEDED: <prompt>] — ask user, re-run with answer appended
[HAIKU_NEEDED: <json>] — spawn Haiku agent to parse NL input, re-run with result
Everything else is deterministic: no LLM reasoning, no context cost.
"""
import sys
import json
import os
import random
from pathlib import Path
from datetime import datetime, timezone
# ── Paths ─────────────────────────────────────────────────────────────────────
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
def find_catalog() -> dict:
pr = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
candidates = [
Path(pr) / "lib" / "catalog.json" if pr else None,
# User-local copy updated by install.sh — checked before stale plugin cache
BUDDYMON_DIR / "catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/catalog.json",
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
]
for p in candidates:
if p and p.exists():
return json.loads(p.read_text())
raise FileNotFoundError("buddymon catalog not found — check plugin installation")
# ── State helpers ─────────────────────────────────────────────────────────────
def load(path) -> dict:
try:
return json.loads(Path(path).read_text())
except Exception:
return {}
def save(path, data):
Path(path).parent.mkdir(parents=True, exist_ok=True)
Path(path).write_text(json.dumps(data, indent=2))
def session_file() -> Path:
key = str(os.getpgrp())
return BUDDYMON_DIR / "sessions" / f"{key}.json"
def get_buddy_id() -> str | None:
try:
ss = load(session_file())
bid = ss.get("buddymon_id")
if bid:
return bid
except Exception:
pass
return load(BUDDYMON_DIR / "active.json").get("buddymon_id")
def get_session_xp() -> int:
try:
return load(session_file()).get("session_xp", 0)
except Exception:
return load(BUDDYMON_DIR / "active.json").get("session_xp", 0)
def add_xp(amount: int):
buddy_id = get_buddy_id()
sf = session_file()
try:
ss = load(sf)
ss["session_xp"] = ss.get("session_xp", 0) + amount
save(sf, ss)
except Exception:
active = load(BUDDYMON_DIR / "active.json")
active["session_xp"] = active.get("session_xp", 0) + amount
save(BUDDYMON_DIR / "active.json", active)
if buddy_id:
roster = load(BUDDYMON_DIR / "roster.json")
if buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + amount
save(BUDDYMON_DIR / "roster.json", roster)
# ── Display helpers ───────────────────────────────────────────────────────────
def xp_bar(current: int, maximum: int, width: int = 20) -> str:
if maximum <= 0:
return "" * width
filled = int(width * min(current, maximum) / maximum)
return "" * filled + "" * (width - filled)
def compute_level(total_xp: int) -> int:
level = 1
while total_xp >= level * 100:
level += 1
return level
# ── Element system (mirrors post-tool-use.py) ─────────────────────────────────
LANGUAGE_ELEMENTS = {
"Python": "dynamic", "Ruby": "dynamic", "JavaScript": "dynamic",
"TypeScript": "typed", "Java": "typed", "C#": "typed",
"Go": "typed", "Rust": "systems", "C": "systems",
"C++": "systems", "Bash": "shell", "Shell": "shell",
"PowerShell": "shell", "HTML": "web", "CSS": "web",
"Vue": "web", "React": "web", "SQL": "data",
"GraphQL": "data", "JSON": "data", "YAML": "data",
"Kotlin": "typed", "Swift": "typed", "PHP": "dynamic",
"Perl": "dynamic", "R": "data", "Julia": "data",
"TOML": "data", "Dockerfile": "systems",
}
ELEMENT_MATCHUPS = {
# (attacker_element, defender_element): multiplier
("dynamic", "typed"): 1.25, ("dynamic", "systems"): 0.85,
("typed", "shell"): 1.25, ("typed", "dynamic"): 0.85,
("systems", "web"): 1.25, ("systems", "typed"): 0.85,
("shell", "dynamic"): 1.25, ("shell", "data"): 0.85,
("web", "data"): 1.25, ("web", "systems"): 0.70,
("data", "dynamic"): 1.25, ("data", "web"): 0.85,
}
LANGUAGE_AFFINITY_TIERS = [
(0, "discovering"),
(50, "familiar"),
(150, "comfortable"),
(350, "proficient"),
(700, "expert"),
(1200, "master"),
]
TIER_EMOJI = {
"discovering": "🔭", "familiar": "📖", "comfortable": "🛠️",
"proficient": "", "expert": "🎯", "master": "👑",
}
def get_player_elements(roster: dict, min_level: int = 2) -> set:
"""Return the set of elements the player has meaningful affinity in."""
elements = set()
for lang, data in roster.get("language_affinities", {}).items():
if data.get("level", 0) >= min_level:
elem = LANGUAGE_ELEMENTS.get(lang)
if elem:
elements.add(elem)
return elements
def element_multiplier(enc: dict, player_elements: set) -> float:
"""Calculate catch rate bonus from element matchup."""
enc_elems = set(enc.get("weak_against", []))
immune_elems = set(enc.get("immune_to", []))
strong_elems = set(enc.get("strong_against", []))
if player_elements & immune_elems:
return 0.70
if player_elements & enc_elems:
return 1.25
if player_elements & strong_elems:
return 0.85
return 1.0
# ── Subcommands ───────────────────────────────────────────────────────────────
def cmd_status():
active = load(BUDDYMON_DIR / "active.json")
roster = load(BUDDYMON_DIR / "roster.json")
enc_data = load(BUDDYMON_DIR / "encounters.json")
if not roster.get("starter_chosen"):
print("No starter chosen yet. Run: /buddymon start")
return
buddy_id = get_buddy_id() or active.get("buddymon_id")
if not buddy_id:
print("No buddy assigned to this session. Run: /buddymon assign <name>")
return
sf = session_file()
try:
ss = load(sf)
session_xp = ss.get("session_xp", 0)
challenge = ss.get("challenge") or active.get("challenge")
except Exception:
session_xp = active.get("session_xp", 0)
challenge = active.get("challenge")
owned = roster.get("owned", {})
buddy = owned.get(buddy_id, {})
display = buddy.get("display", buddy_id)
total_xp = buddy.get("xp", 0)
level = buddy.get("level", compute_level(total_xp))
max_xp = level * 100
xp_in_level = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in_level, max_xp)
ch_name = ""
if challenge:
ch_name = challenge.get("name", str(challenge)) if isinstance(challenge, dict) else str(challenge)
evolved_from = buddy.get("evolved_from", "")
prestige = f" (prestige from {evolved_from})" if evolved_from else ""
print("╔══════════════════════════════════════════╗")
print("║ 🐾 Buddymon ║")
print("╠══════════════════════════════════════════╣")
lv_str = f"Lv.{level}{prestige}"
print(f"║ Active: {display}")
print(f"{lv_str}")
print(f"║ XP: [{bar}] {xp_in_level}/{max_xp}")
print(f"║ Session: +{session_xp} XP")
if ch_name:
print(f"║ Challenge: {ch_name}")
print("╚══════════════════════════════════════════╝")
enc = enc_data.get("active_encounter")
if enc:
enc_display = enc.get("display", "???")
strength = enc.get("current_strength", 100)
wounded = enc.get("wounded", False)
print(f"\n⚔️ Active encounter: {enc_display} [{strength}% strength]")
if wounded:
print(" ⚠️ Wounded — `/buddymon catch` for near-guaranteed capture.")
else:
print(" Run `/buddymon fight` or `/buddymon catch`")
def cmd_fight(confirmed: bool = False):
enc_data = load(BUDDYMON_DIR / "encounters.json")
enc = enc_data.get("active_encounter")
if not enc:
print("No active encounter — it may have already been auto-resolved.")
return
display = enc.get("display", "???")
strength = enc.get("current_strength", 100)
if enc.get("id") == "ShadowBit":
print(f"⚠️ {display} cannot be defeated — use `/buddymon catch` instead.")
return
if not confirmed:
print(f"⚔️ Fighting {display} [{strength}% strength]")
print(f"\n[INPUT_NEEDED: Have you fixed the bug that triggered {display}? (yes/no)]")
return
# Confirmed — award XP and clear
xp = enc.get("xp_reward", 50)
add_xp(xp)
enc["outcome"] = "defeated"
enc["timestamp"] = datetime.now(timezone.utc).isoformat()
enc_data.setdefault("history", []).append(enc)
enc_data["active_encounter"] = None
save(BUDDYMON_DIR / "encounters.json", enc_data)
print(f"✅ Defeated {display}! +{xp} XP")
def cmd_catch(args: list[str]):
enc_data = load(BUDDYMON_DIR / "encounters.json")
enc = enc_data.get("active_encounter")
if not enc:
print("No active encounter.")
return
display = enc.get("display", "???")
current_strength = enc.get("current_strength", 100)
wounded = enc.get("wounded", False)
is_mascot = enc.get("encounter_type") == "language_mascot"
# --strength N means we already have a resolved strength value; go straight to roll
strength_override = None
if "--strength" in args:
idx = args.index("--strength")
try:
strength_override = int(args[idx + 1])
except (IndexError, ValueError):
pass
if strength_override is None and not wounded:
enc["catch_pending"] = True
enc_data["active_encounter"] = enc
save(BUDDYMON_DIR / "encounters.json", enc_data)
print(f"🎯 Catching {display} [{current_strength}% strength]")
print()
if is_mascot:
lang = enc.get("language", "this language")
print("Weakening actions (language mascots weaken through coding):")
print(f" 1) Write 10+ lines in {lang} → -20% strength")
print(f" 2) Refactor existing {lang} code → -15% strength")
print(f" 3) Add type annotations / docs → -10% strength")
else:
print("Weakening actions:")
print(" 1) Write a failing test → -20% strength")
print(" 2) Isolate a minimal repro case → -20% strength")
print(" 3) Add a documenting comment → -10% strength")
print()
print("[INPUT_NEEDED: Which have you done? Enter numbers (e.g. \"1 2\"), describe in words, or \"none\" to throw now]")
return
# Resolve to a final strength and roll
final_strength = strength_override if strength_override is not None else current_strength
enc["catch_pending"] = False
roster = load(BUDDYMON_DIR / "roster.json")
buddy_id = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
try:
catalog = find_catalog()
except Exception:
catalog = {}
buddy_data = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
buddy_level = roster.get("owned", {}).get(buddy_id, {}).get("level", 1)
weakness_bonus = (100 - final_strength) / 100 * 0.4
player_elems = get_player_elements(roster)
elem_mult = element_multiplier(enc, player_elems)
if is_mascot:
# Mascot formula: base catch from mascot catalog + affinity bonus (6% per level)
mascot_data = catalog.get("language_mascots", {}).get(enc.get("id", ""), {})
lang = enc.get("language") or mascot_data.get("language", "")
lang_affinity = roster.get("language_affinities", {}).get(lang, {})
affinity_level = lang_affinity.get("level", 0)
base_catch = mascot_data.get("base_stats", {}).get("catch_rate", 0.35)
affinity_bonus = affinity_level * 0.06
catch_rate = min(0.95, (base_catch + affinity_bonus + weakness_bonus + buddy_level * 0.01) * elem_mult)
else:
base_catch = buddy_data.get("base_stats", {}).get("catch_rate", 0.4)
catch_rate = min(0.95, (base_catch + weakness_bonus + buddy_level * 0.02) * elem_mult)
success = random.random() < catch_rate
if success:
xp = int(enc.get("xp_reward", 50) * 1.5)
if is_mascot:
lang = enc.get("language", "")
caught_entry = {
"id": enc["id"],
"display": enc["display"],
"type": "caught_language_mascot",
"language": lang,
"level": 1,
"xp": 0,
"caught_at": datetime.now(timezone.utc).isoformat(),
}
else:
caught_entry = {
"id": enc["id"],
"display": enc["display"],
"type": "caught_bug_monster",
"level": 1,
"xp": 0,
"caught_at": datetime.now(timezone.utc).isoformat(),
}
roster.setdefault("owned", {})[enc["id"]] = caught_entry
if buddy_id and buddy_id in roster.get("owned", {}):
roster["owned"][buddy_id]["xp"] = roster["owned"][buddy_id].get("xp", 0) + xp
save(BUDDYMON_DIR / "roster.json", roster)
add_xp(xp)
enc["outcome"] = "caught"
enc["timestamp"] = datetime.now(timezone.utc).isoformat()
enc_data.setdefault("history", []).append(enc)
enc_data["active_encounter"] = None
save(BUDDYMON_DIR / "encounters.json", enc_data)
elem_hint = ""
if elem_mult > 1.0:
if is_mascot:
elem_hint = " ⚡ Affinity resonance!"
else:
elem_hint = " ⚡ Super effective!"
elif elem_mult < 0.85:
elem_hint = " 🛡️ Resistant."
if is_mascot:
lang_disp = enc.get("language", "")
print(f"🎉 Caught {display}!{elem_hint} +{xp} XP")
if lang_disp:
print(f" Now assign it as your buddy to unlock {lang_disp} challenges.")
else:
print(f"🎉 Caught {display}!{elem_hint} +{xp} XP (1.5× catch bonus)")
else:
enc_data["active_encounter"] = enc
save(BUDDYMON_DIR / "encounters.json", enc_data)
if is_mascot:
lang = enc.get("language", "this language")
print(f"💨 {display} slipped away! Keep coding in {lang} to weaken it further. ({int(catch_rate * 100)}% catch rate)")
else:
print(f"💨 {display} broke free! Weaken it further and try again. ({int(catch_rate * 100)}% catch rate)")
def _evo_chain_label(bid: str, owned: dict, catalog: dict) -> str:
"""Build a short evolution chain string: A → B → C with current marked."""
# Walk backwards to find root
root = bid
while True:
prev = owned.get(root, {}).get("evolved_from") or catalog.get("evolutions", {}).get(root, {}).get("evolves_from")
if not prev or prev not in owned:
break
root = prev
# Walk forward
chain = [root]
cur = root
while True:
nxt = owned.get(cur, {}).get("evolved_into")
if not nxt or nxt not in owned:
break
chain.append(nxt)
cur = nxt
if len(chain) < 2:
return ""
return "".join(
f"[{n}]" if n == bid else n
for n in chain
)
def cmd_roster():
import io as _io
_buf = _io.StringIO()
def _p(*args, **kwargs):
"""print() that writes to the buffer instead of stdout."""
kwargs.setdefault("file", _buf)
print(*args, **kwargs)
roster = load(BUDDYMON_DIR / "roster.json")
owned = roster.get("owned", {})
active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
try:
catalog = find_catalog()
except Exception:
catalog = {}
mascot_catalog = catalog.get("language_mascots", {})
bug_catalog = catalog.get("bug_monsters", {})
core_buddymon = {k: v for k, v in owned.items()
if v.get("type") not in ("caught_bug_monster", "caught_language_mascot")}
mascots_owned = {k: v for k, v in owned.items() if v.get("type") == "caught_language_mascot"}
caught_bugs = {k: v for k, v in owned.items() if v.get("type") == "caught_bug_monster"}
total_owned = len(core_buddymon) + len(mascots_owned) + len(caught_bugs)
bug_total = len(bug_catalog)
mascot_total = len(mascot_catalog)
W = 52
owned_tag = f"{total_owned} owned"
pad = W - 10 - len(owned_tag)
_p("" + "" * W + "")
_p(f"║ 🐾 ROSTER{' ' * pad}{owned_tag}")
_p("" + "" * W + "")
# ── BUDDYMON ──────────────────────────────────────────────
_p()
_p("── BUDDYMON ✓ levels via XP · assignable " + "" * 8)
for bid, b in sorted(core_buddymon.items(),
key=lambda x: -x[1].get("xp", 0)):
lvl = b.get("level", 1)
total_xp = b.get("xp", 0)
max_xp = lvl * 100
xp_in = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in, max_xp, width=16)
display = b.get("display", bid)
affinity = b.get("affinity", "")
aff_tag = f" [{affinity}]" if affinity else ""
active_tag = " ← ACTIVE" if bid == active_bid else ""
chain = _evo_chain_label(bid, owned, catalog)
cat_entry = catalog.get("buddymon", {}).get(bid) or catalog.get("evolutions", {}).get(bid) or {}
evos = cat_entry.get("evolutions", [])
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
_p()
_p(f" {display}{aff_tag}{active_tag}")
_p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
if chain:
_p(f" chain: {chain}")
if next_evo:
gap = next_evo["level"] - lvl
_p(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']} ({gap} levels to go)")
elif b.get("evolved_into") and b["evolved_into"] in owned:
_p(f" ✓ fully evolved")
# ── LANGUAGE MASCOTS ──────────────────────────────────────
_p()
_p("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "" * 1)
if mascots_owned:
for bid, b in sorted(mascots_owned.items(),
key=lambda x: -x[1].get("xp", 0)):
display = b.get("display", bid)
lang = b.get("language", "")
lvl = b.get("level", 1)
total_xp = b.get("xp", 0)
max_xp = lvl * 100
xp_in = total_xp % max_xp if max_xp else 0
bar = xp_bar(xp_in, max_xp, width=16)
mc = mascot_catalog.get(bid, {})
elem = mc.get("element", "")
elem_tag = f" [{elem}]" if elem else ""
evos = mc.get("evolutions", [])
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
active_tag = " ← ACTIVE" if bid == active_bid else ""
_p()
_p(f" {display} [{lang}]{elem_tag}{active_tag}")
_p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
if next_evo:
_p(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}")
else:
_p(f" (none yet — code in Python, JS, Rust… to encounter them)")
_p(f" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers")
# ── LANGUAGE AFFINITIES ───────────────────────────────────
affinities = roster.get("language_affinities", {})
if affinities:
_p()
_p("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "" * 0)
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
tier = data.get("tier", "discovering")
level = data.get("level", 0)
xp = data.get("xp", 0)
emoji = TIER_EMOJI.get(tier, "🔭")
elem = LANGUAGE_ELEMENTS.get(lang, "")
e_tag = f"[{elem}]" if elem else ""
has_mascot = any(m.get("language") == lang for m in mascot_catalog.values())
m_tag = " 🦎" if has_mascot else ""
_p(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}")
# ── BUG TROPHY CASE ───────────────────────────────────────
_p()
caught_count = len(caught_bugs)
_p(f"── BUG TROPHY CASE ✗ trophies only · no leveling ({caught_count}/{bug_total}) " + "" * 0)
if caught_bugs:
# Sort: notable (level>1 or has XP) first, then alpha
def bug_sort_key(item):
b = item[1]
notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0
return (not notable, item[0])
items = sorted(caught_bugs.items(), key=bug_sort_key)
# Two-column grid — 28 chars per column; cap at COL so long names don't push
# the right column out of alignment.
COL = 28
for i in range(0, len(items), 2):
left_id, left = items[i]
right_id, right = items[i + 1] if i + 1 < len(items) else (None, None)
def cell(bid, b):
if b is None:
return ""
disp = b.get("display", bid)
lvl = b.get("level", 1)
bc = bug_catalog.get(bid, {})
weak = bc.get("weak_against", [])
elem = f"[{weak[0]}]" if weak else ""
lvl_tag = f" Lv.{lvl}" if lvl > 1 else ""
cell_str = f"{disp}{lvl_tag} {elem}".strip()
return cell_str[:COL].ljust(COL)
l = cell(left_id, left)
r = cell(right_id, right) if right else ""
_p(f" {l} {r}".rstrip())
else:
_p(" none yet")
# ── DISCOVERY FOOTER ──────────────────────────────────────
missing_bugs = bug_total - len(caught_bugs)
missing_mascots = mascot_total - len(mascots_owned)
if missing_bugs + missing_mascots > 0:
parts = []
if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters")
if missing_mascots > 0: parts.append(f"{missing_mascots} mascots")
_p(f"\n ❓ ??? — {' and '.join(parts)} still to discover")
# Write roster output to a file for the Stop hook to deliver as additionalContext.
# The Stop hook reads roster_output.txt directly — it does NOT re-run this command,
# avoiding any recursive flag-write loop.
output_text = _buf.getvalue()
roster_output = BUDDYMON_DIR / "roster_output.txt"
roster_pending = BUDDYMON_DIR / "roster_pending.txt"
roster_output.write_text(output_text)
roster_pending.write_text("1")
# Print to stdout only when running interactively (direct terminal use).
# When called from a skill via Bash (piped), output is silent — the Stop
# hook delivers it as additionalContext instead.
if sys.stdout.isatty():
sys.stdout.write(output_text)
def cmd_start(choice: str | None = None):
roster = load(BUDDYMON_DIR / "roster.json")
if roster.get("starter_chosen"):
print("Starter already chosen! Run `/buddymon status` or `/buddymon roster`.")
return
starters = {
"1": ("Pyrobyte", "🔥", "Speedrunner — loves tight deadlines and feature sprints"),
"2": ("Debuglin", "🔍", "Tester — patient, methodical, ruthless bug hunter"),
"3": ("Minimox", "✂️", "Cleaner — obsessed with fewer lines and zero linter runs"),
}
if choice is None:
print("╔══════════════════════════════════════════════════════════╗")
print("║ 🐾 Choose Your Starter Buddymon ║")
print("╠══════════════════════════════════════════════════════════╣")
for num, (name, emoji, desc) in starters.items():
print(f"║ [{num}] {emoji} {name:<10}{desc:<36}")
print("╚══════════════════════════════════════════════════════════╝")
print("\n[INPUT_NEEDED: Choose 1, 2, or 3]")
return
# Normalize choice
key = choice.strip().lower()
name_map = {v[0].lower(): k for k, v in starters.items()}
if key in name_map:
key = name_map[key]
if key not in starters:
print(f"Invalid choice '{choice}'. Please choose 1, 2, or 3.")
return
bid, emoji, _ = starters[key]
try:
catalog = find_catalog()
except Exception:
catalog = {}
buddy_cat = catalog.get("buddymon", {}).get(bid, {})
challenges = buddy_cat.get("challenges", [])
roster.setdefault("owned", {})[bid] = {
"id": bid,
"display": f"{emoji} {bid}",
"affinity": buddy_cat.get("affinity", ""),
"level": 1,
"xp": 0,
}
roster["starter_chosen"] = True
save(BUDDYMON_DIR / "roster.json", roster)
active = load(BUDDYMON_DIR / "active.json")
active["buddymon_id"] = bid
active["session_xp"] = 0
active["challenge"] = challenges[0] if challenges else None
save(BUDDYMON_DIR / "active.json", active)
sf = session_file()
try:
ss = load(sf)
except Exception:
ss = {}
ss["buddymon_id"] = bid
ss["session_xp"] = 0
ss["challenge"] = active["challenge"]
save(sf, ss)
print(f"✨ You chose {emoji} {bid}!")
if challenges:
ch = challenges[0]
ch_name = ch.get("name", str(ch)) if isinstance(ch, dict) else str(ch)
print(f" First challenge: {ch_name}")
print("\nBug monsters will appear when errors occur in your terminal.")
print("Run `/buddymon` any time to check your status.")
def cmd_assign(args: list[str]):
"""
assign → list roster + [INPUT_NEEDED]
assign <name> → fuzzy match → show challenge → [INPUT_NEEDED: accept/decline/reroll]
assign <name> --accept
assign <name> --reroll
assign <name> --resolved <buddy_id> (Haiku resolved ambiguity)
"""
roster = load(BUDDYMON_DIR / "roster.json")
owned = roster.get("owned", {})
buddymon_ids = [k for k, v in owned.items() if v.get("type") != "caught_bug_monster"]
if not buddymon_ids:
print("No Buddymon owned yet. Run `/buddymon start` first.")
return
# No name given
if not args or args[0].startswith("--"):
print("Your Buddymon:")
for bid in buddymon_ids:
b = owned[bid]
print(f" {b.get('display', bid)} Lv.{b.get('level', 1)}")
print("\n[INPUT_NEEDED: Which buddy do you want for this session?]")
return
name_input = args[0]
rest_flags = args[1:]
# --resolved bypasses fuzzy match (Haiku already picked)
if "--resolved" in rest_flags:
idx = rest_flags.index("--resolved")
try:
resolved_id = rest_flags[idx + 1]
except IndexError:
resolved_id = name_input
return cmd_assign([resolved_id] + [f for f in rest_flags if f != "--resolved" and f != resolved_id])
# Fuzzy match
query = name_input.lower()
matches = [bid for bid in buddymon_ids if query in bid.lower()]
if not matches:
print(f"No Buddymon matching '{name_input}'. Your roster:")
for bid in buddymon_ids:
print(f" {owned[bid].get('display', bid)}")
return
if len(matches) > 1:
# Emit Haiku marker to resolve ambiguity
candidates = [owned[m].get("display", m) for m in matches]
haiku_task = json.dumps({
"task": "fuzzy_match",
"input": name_input,
"candidates": candidates,
"instruction": "The user typed a partial buddy name. Which candidate do they most likely mean? Reply with ONLY the exact candidate string.",
})
print(f"[HAIKU_NEEDED: {haiku_task}]")
return
bid = matches[0]
b = owned[bid]
display = b.get("display", bid)
try:
catalog = find_catalog()
except Exception:
catalog = {}
buddy_cat = (catalog.get("buddymon", {}).get(bid)
or catalog.get("evolutions", {}).get(bid) or {})
challenges = buddy_cat.get("challenges", [])
if "--accept" in rest_flags:
# Write session state
sf = session_file()
try:
ss = load(sf)
except Exception:
ss = {}
chosen_challenge = None
if challenges:
# Pick randomly unless a specific index was passed
if "--challenge-idx" in rest_flags:
idx = rest_flags.index("--challenge-idx")
try:
ci = int(rest_flags[idx + 1])
chosen_challenge = challenges[ci % len(challenges)]
except (IndexError, ValueError):
chosen_challenge = random.choice(challenges)
else:
chosen_challenge = random.choice(challenges)
ss["buddymon_id"] = bid
ss["session_xp"] = ss.get("session_xp", 0)
ss["challenge"] = chosen_challenge
save(sf, ss)
# Also update global default
active = load(BUDDYMON_DIR / "active.json")
active["buddymon_id"] = bid
active["challenge"] = chosen_challenge
save(BUDDYMON_DIR / "active.json", active)
ch_name = ""
if chosen_challenge:
ch_name = chosen_challenge.get("name", str(chosen_challenge)) if isinstance(chosen_challenge, dict) else str(chosen_challenge)
print(f"{display} assigned to this session!")
if ch_name:
print(f" Challenge: {ch_name}")
return
if "--reroll" in rest_flags:
# Pick a different challenge and re-show the proposal
if not challenges:
print(f"No challenges available for {display}.")
return
challenge = random.choice(challenges)
elif challenges:
challenge = challenges[0]
else:
challenge = None
ch_name = ""
ch_desc = ""
ch_stars = ""
if challenge:
if isinstance(challenge, dict):
ch_name = challenge.get("name", "")
ch_desc = challenge.get("description", "")
difficulty = challenge.get("difficulty", 1)
ch_stars = "" * difficulty + "" * (5 - difficulty)
else:
ch_name = str(challenge)
print(f"🐾 Assign {display} to this session?")
if ch_name:
print(f"\n Challenge: {ch_name}")
if ch_desc:
print(f" {ch_desc}")
if ch_stars:
print(f" Difficulty: {ch_stars}")
print("\n[INPUT_NEEDED: accept / decline / reroll]")
def cmd_evolve(confirmed: bool = False):
roster = load(BUDDYMON_DIR / "roster.json")
active = load(BUDDYMON_DIR / "active.json")
buddy_id = get_buddy_id() or active.get("buddymon_id")
if not buddy_id:
print("No buddy assigned.")
return
try:
catalog = find_catalog()
except Exception:
catalog = {}
owned = roster.get("owned", {})
buddy = owned.get(buddy_id, {})
display = buddy.get("display", buddy_id)
level = buddy.get("level", 1)
catalog_entry = (catalog.get("buddymon", {}).get(buddy_id)
or catalog.get("evolutions", {}).get(buddy_id) or {})
evolutions = catalog_entry.get("evolutions", [])
evo = next((e for e in evolutions if level >= e.get("level", 999)), None)
if not evo:
target_level = min((e.get("level", 100) for e in evolutions), default=100)
levels_to_go = max(0, target_level - level)
print(f"{display} is Lv.{level}. Evolution requires Lv.{target_level} ({levels_to_go} more levels).")
return
into_id = evo["into"]
into_data = catalog.get("evolutions", {}).get(into_id, {})
into_display = into_data.get("display", into_id)
old_catch = catalog_entry.get("base_stats", {}).get("catch_rate", 0.4)
new_catch = into_data.get("base_stats", {}).get("catch_rate", old_catch)
old_mult = catalog_entry.get("base_stats", {}).get("xp_multiplier", 1.0)
new_mult = into_data.get("base_stats", {}).get("xp_multiplier", old_mult)
if not confirmed:
print("╔══════════════════════════════════════════════════════════╗")
print("║ ✨ Evolution Ready! ║")
print("╠══════════════════════════════════════════════════════════╣")
print(f"{display} Lv.{level}{into_display}")
print(f"║ catch_rate: {old_catch:.2f}{new_catch:.2f} · xp_multiplier: {old_mult}{new_mult}")
print("║ ⚠️ Resets to Lv.1. Caught monsters stay.")
print("╚══════════════════════════════════════════════════════════╝")
print("\n[INPUT_NEEDED: Evolve? (y/n)]")
return
# Execute evolution
now = datetime.now(timezone.utc).isoformat()
owned[buddy_id]["evolved_into"] = into_id
owned[buddy_id]["evolved_at"] = now
challenges = catalog_entry.get("challenges") or into_data.get("challenges", [])
owned[into_id] = {
"id": into_id,
"display": into_display,
"affinity": into_data.get("affinity", catalog_entry.get("affinity", "")),
"level": 1,
"xp": 0,
"evolved_from": buddy_id,
"evolved_at": now,
}
roster["owned"] = owned
save(BUDDYMON_DIR / "roster.json", roster)
sf = session_file()
try:
ss = load(sf)
except Exception:
ss = {}
ss["buddymon_id"] = into_id
ss["session_xp"] = 0
ss["challenge"] = challenges[0] if challenges else None
save(sf, ss)
active["buddymon_id"] = into_id
active["session_xp"] = 0
active["challenge"] = ss["challenge"]
save(BUDDYMON_DIR / "active.json", active)
ch_name = ""
if ss["challenge"]:
ch = ss["challenge"]
ch_name = ch.get("name", str(ch)) if isinstance(ch, dict) else str(ch)
print(f"{display} evolved into {into_display}!")
print(" Starting fresh at Lv.1 — the second climb is faster.")
if ch_name:
print(f" New challenge: {ch_name}")
def cmd_statusline():
settings_path = Path.home() / ".claude" / "settings.json"
script_candidates = [
Path.home() / ".claude/buddymon/statusline.sh",
Path(os.environ.get("CLAUDE_PLUGIN_ROOT", "")) / "lib/statusline.sh",
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.0/lib/statusline.sh",
]
script_path = next((str(p) for p in script_candidates if p.exists()), None)
if not script_path:
print("❌ statusline.sh not found. Re-run install.sh.")
return
settings = load(settings_path)
if settings.get("statusLine"):
existing = settings["statusLine"].get("command", "")
print(f"⚠️ A statusLine is already configured:\n {existing}")
print("\n[INPUT_NEEDED: Replace it? (y/n)]")
return
settings["statusLine"] = {"type": "command", "command": f"bash {script_path}"}
save(settings_path, settings)
print(f"✅ Buddymon statusline installed. Reload Claude Code to activate.")
def cmd_help():
print("""/buddymon — status panel
/buddymon start — choose starter (first run)
/buddymon assign <n> — assign buddy to session
/buddymon fight — fight active encounter
/buddymon catch — catch active encounter
/buddymon roster — view full roster
/buddymon evolve — evolve buddy (Lv.100)
/buddymon statusline — install statusline widget
/buddymon help — this list""")
# ── Entry point ───────────────────────────────────────────────────────────────
def main():
args = sys.argv[1:]
cmd = args[0].lower() if args else ""
rest = args[1:]
dispatch = {
"": lambda: cmd_status(),
"status": lambda: cmd_status(),
"fight": lambda: cmd_fight(confirmed="--confirmed" in rest),
"catch": lambda: cmd_catch(rest),
"roster": lambda: cmd_roster(),
"start": lambda: cmd_start(rest[0] if rest else None),
"assign": lambda: cmd_assign(rest),
"evolve": lambda: cmd_evolve(confirmed="--confirm" in rest),
"statusline": lambda: cmd_statusline(),
"help": lambda: cmd_help(),
}
handler = dispatch.get(cmd)
if handler:
handler()
else:
print(f"Unknown subcommand '{cmd}'. Run `/buddymon help` for the full list.")
sys.exit(1)
if __name__ == "__main__":
main()