Compare commits
4 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6c1d23c81 | ||
|
|
1edbfb9007 | ||
|
|
67e47de7ca | ||
|
|
81712d06e3 |
6 changed files with 134 additions and 72 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "buddymon",
|
"name": "buddymon",
|
||||||
"description": "Collectible creatures discovered through coding — commit streaks, bug fights, and session challenges",
|
"description": "Collectible creatures discovered through coding \u2014 commit streaks, bug fights, and session challenges",
|
||||||
"version": "0.2.1",
|
"version": "0.2.3",
|
||||||
"author": {
|
"author": {
|
||||||
"name": "CircuitForge LLC",
|
"name": "CircuitForge LLC",
|
||||||
"email": "hello@circuitforge.tech"
|
"email": "hello@circuitforge.tech"
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,31 @@ import random
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
|
|
||||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
||||||
CATALOG_FILE = Path(PLUGIN_ROOT) / "lib" / "catalog.json"
|
|
||||||
|
|
||||||
|
def find_catalog() -> dict:
|
||||||
|
"""Load catalog from the first candidate path that exists.
|
||||||
|
|
||||||
|
Checks the user-local copy installed by install.sh first, so the
|
||||||
|
current catalog is always used regardless of which plugin cache version
|
||||||
|
CLAUDE_PLUGIN_ROOT points to.
|
||||||
|
"""
|
||||||
|
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", str(Path(__file__).parent.parent))
|
||||||
|
candidates = [
|
||||||
|
# User-local copy: always matches the live dev/install version
|
||||||
|
BUDDYMON_DIR / "catalog.json",
|
||||||
|
Path(plugin_root) / "lib" / "catalog.json",
|
||||||
|
Path.home() / ".claude/plugins/cache/circuitforge/buddymon/0.1.1/lib/catalog.json",
|
||||||
|
Path.home() / ".claude/plugins/marketplaces/circuitforge/plugins/buddymon/lib/catalog.json",
|
||||||
|
]
|
||||||
|
for p in candidates:
|
||||||
|
if p and p.exists():
|
||||||
|
try:
|
||||||
|
return json.loads(p.read_text())
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return {}
|
||||||
|
|
||||||
# Each CC session gets its own state file keyed by process group ID.
|
# Each CC session gets its own state file keyed by process group ID.
|
||||||
# All hooks within one session share the same PGRP, giving stable per-session state.
|
# All hooks within one session share the same PGRP, giving stable per-session state.
|
||||||
|
|
@ -531,7 +553,7 @@ def main():
|
||||||
BUDDYMON_DIR.mkdir(parents=True, exist_ok=True)
|
BUDDYMON_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
catalog = load_json(CATALOG_FILE)
|
catalog = find_catalog()
|
||||||
buddy_id = get_active_buddy_id()
|
buddy_id = get_active_buddy_id()
|
||||||
|
|
||||||
# Look up display name
|
# Look up display name
|
||||||
|
|
@ -663,6 +685,16 @@ def main():
|
||||||
target = monster or event
|
target = monster or event
|
||||||
|
|
||||||
if target and random.random() < 0.70:
|
if target and random.random() < 0.70:
|
||||||
|
# Skip spawn if this monster is already in the collection —
|
||||||
|
# no point announcing something the player already caught.
|
||||||
|
target_id = target.get("id", "")
|
||||||
|
_owned = load_json(BUDDYMON_DIR / "roster.json").get("owned", {})
|
||||||
|
already_owned = (target_id in _owned
|
||||||
|
and _owned[target_id].get("type")
|
||||||
|
in ("caught_bug_monster", "caught_language_mascot"))
|
||||||
|
if already_owned:
|
||||||
|
pass # silently skip
|
||||||
|
else:
|
||||||
if monster:
|
if monster:
|
||||||
strength = compute_strength(monster, elapsed_minutes=0)
|
strength = compute_strength(monster, elapsed_minutes=0)
|
||||||
else:
|
else:
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,12 @@ If present, runs the roster CLI and emits full output as additionalContext,
|
||||||
guaranteeing the full roster is visible without Bash-tool truncation.
|
guaranteeing the full roster is visible without Bash-tool truncation.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
BUDDYMON_DIR = Path.home() / ".claude" / "buddymon"
|
||||||
PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt"
|
PENDING_FLAG = BUDDYMON_DIR / "roster_pending.txt"
|
||||||
CLI = BUDDYMON_DIR / "cli.py"
|
OUTPUT_FILE = BUDDYMON_DIR / "roster_output.txt"
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -26,16 +24,16 @@ def main():
|
||||||
if not PENDING_FLAG.exists():
|
if not PENDING_FLAG.exists():
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Clear both files before reading — prevents a second Stop event from
|
||||||
|
# re-delivering the same roster if OUTPUT_FILE lingers.
|
||||||
PENDING_FLAG.unlink(missing_ok=True)
|
PENDING_FLAG.unlink(missing_ok=True)
|
||||||
|
|
||||||
if not CLI.exists():
|
if not OUTPUT_FILE.exists():
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
result = subprocess.run(
|
output = OUTPUT_FILE.read_text().strip()
|
||||||
["python3", str(CLI), "roster"],
|
OUTPUT_FILE.unlink(missing_ok=True)
|
||||||
capture_output=True, text=True, timeout=10,
|
|
||||||
)
|
|
||||||
output = result.stdout.strip()
|
|
||||||
if not output:
|
if not output:
|
||||||
sys.exit(0)
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,11 @@ SESSION_FILE = BUDDYMON_DIR / "sessions" / f"{SESSION_KEY}.json"
|
||||||
def get_session_state() -> dict:
|
def get_session_state() -> dict:
|
||||||
try:
|
try:
|
||||||
with open(SESSION_FILE) as f:
|
with open(SESSION_FILE) as f:
|
||||||
return json.load(f)
|
data = json.load(f)
|
||||||
|
# Fall through when file exists but buddymon_id is null (pgrp mismatch).
|
||||||
|
if not data.get("buddymon_id"):
|
||||||
|
raise ValueError("null buddymon_id")
|
||||||
|
return data
|
||||||
except Exception:
|
except Exception:
|
||||||
global_active = {}
|
global_active = {}
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
10
install.sh
10
install.sh
|
|
@ -290,6 +290,16 @@ PYEOF
|
||||||
cp "${REPO_DIR}/lib/catalog.json" "${BUDDYMON_DIR}/catalog.json"
|
cp "${REPO_DIR}/lib/catalog.json" "${BUDDYMON_DIR}/catalog.json"
|
||||||
ok "Installed catalog.json → ${BUDDYMON_DIR}/catalog.json"
|
ok "Installed catalog.json → ${BUDDYMON_DIR}/catalog.json"
|
||||||
|
|
||||||
|
# Sync hooks-handlers and hooks.json into the live cache so edits take effect
|
||||||
|
# without waiting for a CC plugin-cache refresh. CC re-reads hook scripts on
|
||||||
|
# every invocation but only reads hooks.json at session start.
|
||||||
|
if [[ -d "${CACHE_DIR}/hooks-handlers" ]]; then
|
||||||
|
cp "${REPO_DIR}/hooks-handlers/"*.py "${CACHE_DIR}/hooks-handlers/"
|
||||||
|
cp "${REPO_DIR}/hooks-handlers/"*.sh "${CACHE_DIR}/hooks-handlers/" 2>/dev/null || true
|
||||||
|
cp "${REPO_DIR}/hooks/hooks.json" "${CACHE_DIR}/hooks/hooks.json"
|
||||||
|
ok "Synced hooks-handlers + hooks.json → ${CACHE_DIR}/"
|
||||||
|
fi
|
||||||
|
|
||||||
# Install statusline into settings.json if not already configured
|
# Install statusline into settings.json if not already configured
|
||||||
python3 << PYEOF
|
python3 << PYEOF
|
||||||
import json
|
import json
|
||||||
|
|
|
||||||
86
lib/cli.py
86
lib/cli.py
|
|
@ -441,6 +441,14 @@ def _evo_chain_label(bid: str, owned: dict, catalog: dict) -> str:
|
||||||
|
|
||||||
|
|
||||||
def cmd_roster():
|
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")
|
roster = load(BUDDYMON_DIR / "roster.json")
|
||||||
owned = roster.get("owned", {})
|
owned = roster.get("owned", {})
|
||||||
active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
|
active_bid = get_buddy_id() or load(BUDDYMON_DIR / "active.json").get("buddymon_id")
|
||||||
|
|
@ -464,13 +472,13 @@ def cmd_roster():
|
||||||
W = 52
|
W = 52
|
||||||
owned_tag = f"{total_owned} owned"
|
owned_tag = f"{total_owned} owned"
|
||||||
pad = W - 10 - len(owned_tag)
|
pad = W - 10 - len(owned_tag)
|
||||||
print("╔" + "═" * W + "╗")
|
_p("╔" + "═" * W + "╗")
|
||||||
print(f"║ 🐾 ROSTER{' ' * pad}{owned_tag} ║")
|
_p(f"║ 🐾 ROSTER{' ' * pad}{owned_tag} ║")
|
||||||
print("╚" + "═" * W + "╝")
|
_p("╚" + "═" * W + "╝")
|
||||||
|
|
||||||
# ── BUDDYMON ──────────────────────────────────────────────
|
# ── BUDDYMON ──────────────────────────────────────────────
|
||||||
print()
|
_p()
|
||||||
print("── BUDDYMON ✓ levels via XP · assignable " + "─" * 8)
|
_p("── BUDDYMON ✓ levels via XP · assignable " + "─" * 8)
|
||||||
for bid, b in sorted(core_buddymon.items(),
|
for bid, b in sorted(core_buddymon.items(),
|
||||||
key=lambda x: -x[1].get("xp", 0)):
|
key=lambda x: -x[1].get("xp", 0)):
|
||||||
lvl = b.get("level", 1)
|
lvl = b.get("level", 1)
|
||||||
|
|
@ -484,26 +492,25 @@ def cmd_roster():
|
||||||
|
|
||||||
active_tag = " ← ACTIVE" if bid == active_bid else ""
|
active_tag = " ← ACTIVE" if bid == active_bid else ""
|
||||||
chain = _evo_chain_label(bid, owned, catalog)
|
chain = _evo_chain_label(bid, owned, catalog)
|
||||||
chain_tag = f" {chain}" if chain else ""
|
|
||||||
|
|
||||||
cat_entry = catalog.get("buddymon", {}).get(bid) or catalog.get("evolutions", {}).get(bid) or {}
|
cat_entry = catalog.get("buddymon", {}).get(bid) or catalog.get("evolutions", {}).get(bid) or {}
|
||||||
evos = cat_entry.get("evolutions", [])
|
evos = cat_entry.get("evolutions", [])
|
||||||
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
|
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
|
||||||
|
|
||||||
print()
|
_p()
|
||||||
print(f" {display}{aff_tag}{active_tag}")
|
_p(f" {display}{aff_tag}{active_tag}")
|
||||||
print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
|
_p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
|
||||||
if chain:
|
if chain:
|
||||||
print(f" chain: {chain}")
|
_p(f" chain: {chain}")
|
||||||
if next_evo:
|
if next_evo:
|
||||||
gap = next_evo["level"] - lvl
|
gap = next_evo["level"] - lvl
|
||||||
print(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']} ({gap} levels to go)")
|
_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:
|
elif b.get("evolved_into") and b["evolved_into"] in owned:
|
||||||
print(f" ✓ fully evolved")
|
_p(f" ✓ fully evolved")
|
||||||
|
|
||||||
# ── LANGUAGE MASCOTS ──────────────────────────────────────
|
# ── LANGUAGE MASCOTS ──────────────────────────────────────
|
||||||
print()
|
_p()
|
||||||
print("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "─" * 1)
|
_p("── LANGUAGE MASCOTS ✓ levels via XP · assignable " + "─" * 1)
|
||||||
if mascots_owned:
|
if mascots_owned:
|
||||||
for bid, b in sorted(mascots_owned.items(),
|
for bid, b in sorted(mascots_owned.items(),
|
||||||
key=lambda x: -x[1].get("xp", 0)):
|
key=lambda x: -x[1].get("xp", 0)):
|
||||||
|
|
@ -521,20 +528,20 @@ def cmd_roster():
|
||||||
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
|
next_evo = next((e for e in evos if lvl < e.get("level", 999)), None)
|
||||||
active_tag = " ← ACTIVE" if bid == active_bid else ""
|
active_tag = " ← ACTIVE" if bid == active_bid else ""
|
||||||
|
|
||||||
print()
|
_p()
|
||||||
print(f" {display} [{lang}]{elem_tag}{active_tag}")
|
_p(f" {display} [{lang}]{elem_tag}{active_tag}")
|
||||||
print(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
|
_p(f" {'Lv.' + str(lvl):<8} XP: [{bar}] {xp_in}/{max_xp}")
|
||||||
if next_evo:
|
if next_evo:
|
||||||
print(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}")
|
_p(f" → evolves to {next_evo['into']} at Lv.{next_evo['level']}")
|
||||||
else:
|
else:
|
||||||
print(f" (none yet — code in Python, JS, Rust… to encounter them)")
|
_p(f" (none yet — code in Python, JS, Rust… to encounter them)")
|
||||||
print(f" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers")
|
_p(f" {mascot_total} species to discover across {len(set(m.get('rarity','') for m in mascot_catalog.values()))} rarity tiers")
|
||||||
|
|
||||||
# ── LANGUAGE AFFINITIES ───────────────────────────────────
|
# ── LANGUAGE AFFINITIES ───────────────────────────────────
|
||||||
affinities = roster.get("language_affinities", {})
|
affinities = roster.get("language_affinities", {})
|
||||||
if affinities:
|
if affinities:
|
||||||
print()
|
_p()
|
||||||
print("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "─" * 0)
|
_p("── LANGUAGE AFFINITIES ◑ passive · levels via edits " + "─" * 0)
|
||||||
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
|
for lang, data in sorted(affinities.items(), key=lambda x: -x[1].get("xp", 0)):
|
||||||
tier = data.get("tier", "discovering")
|
tier = data.get("tier", "discovering")
|
||||||
level = data.get("level", 0)
|
level = data.get("level", 0)
|
||||||
|
|
@ -544,12 +551,12 @@ def cmd_roster():
|
||||||
e_tag = f"[{elem}]" if elem else ""
|
e_tag = f"[{elem}]" if elem else ""
|
||||||
has_mascot = any(m.get("language") == lang for m in mascot_catalog.values())
|
has_mascot = any(m.get("language") == lang for m in mascot_catalog.values())
|
||||||
m_tag = " 🦎" if has_mascot else ""
|
m_tag = " 🦎" if has_mascot else ""
|
||||||
print(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}")
|
_p(f" {emoji} {lang:<13} {tier:<12} Lv.{level:<3} · {xp:>5} XP {e_tag}{m_tag}")
|
||||||
|
|
||||||
# ── BUG TROPHY CASE ───────────────────────────────────────
|
# ── BUG TROPHY CASE ───────────────────────────────────────
|
||||||
print()
|
_p()
|
||||||
caught_count = len(caught_bugs)
|
caught_count = len(caught_bugs)
|
||||||
print(f"── BUG TROPHY CASE ✗ trophies only · no leveling ({caught_count}/{bug_total}) " + "─" * 0)
|
_p(f"── BUG TROPHY CASE ✗ trophies only · no leveling ({caught_count}/{bug_total}) " + "─" * 0)
|
||||||
if caught_bugs:
|
if caught_bugs:
|
||||||
# Sort: notable (level>1 or has XP) first, then alpha
|
# Sort: notable (level>1 or has XP) first, then alpha
|
||||||
def bug_sort_key(item):
|
def bug_sort_key(item):
|
||||||
|
|
@ -557,7 +564,8 @@ def cmd_roster():
|
||||||
notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0
|
notable = b.get("level", 1) > 1 or b.get("xp", 0) > 0
|
||||||
return (not notable, item[0])
|
return (not notable, item[0])
|
||||||
items = sorted(caught_bugs.items(), key=bug_sort_key)
|
items = sorted(caught_bugs.items(), key=bug_sort_key)
|
||||||
# Two-column grid — 28 chars gives room for "🦅 ReviewHawk Lv.64 [systems]"
|
# Two-column grid — 28 chars per column; cap at COL so long names don't push
|
||||||
|
# the right column out of alignment.
|
||||||
COL = 28
|
COL = 28
|
||||||
for i in range(0, len(items), 2):
|
for i in range(0, len(items), 2):
|
||||||
left_id, left = items[i]
|
left_id, left = items[i]
|
||||||
|
|
@ -572,14 +580,14 @@ def cmd_roster():
|
||||||
weak = bc.get("weak_against", [])
|
weak = bc.get("weak_against", [])
|
||||||
elem = f"[{weak[0]}]" if weak else ""
|
elem = f"[{weak[0]}]" if weak else ""
|
||||||
lvl_tag = f" Lv.{lvl}" if lvl > 1 else ""
|
lvl_tag = f" Lv.{lvl}" if lvl > 1 else ""
|
||||||
cell_str = f"{disp}{lvl_tag} {elem}"
|
cell_str = f"{disp}{lvl_tag} {elem}".strip()
|
||||||
return cell_str[:COL].ljust(COL)
|
return cell_str[:COL].ljust(COL)
|
||||||
|
|
||||||
l = cell(left_id, left)
|
l = cell(left_id, left)
|
||||||
r = cell(right_id, right) if right else ""
|
r = cell(right_id, right) if right else ""
|
||||||
print(f" {l} {r}".rstrip())
|
_p(f" {l} {r}".rstrip())
|
||||||
else:
|
else:
|
||||||
print(" none yet")
|
_p(" none yet")
|
||||||
|
|
||||||
# ── DISCOVERY FOOTER ──────────────────────────────────────
|
# ── DISCOVERY FOOTER ──────────────────────────────────────
|
||||||
missing_bugs = bug_total - len(caught_bugs)
|
missing_bugs = bug_total - len(caught_bugs)
|
||||||
|
|
@ -588,12 +596,22 @@ def cmd_roster():
|
||||||
parts = []
|
parts = []
|
||||||
if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters")
|
if missing_bugs > 0: parts.append(f"{missing_bugs} bug monsters")
|
||||||
if missing_mascots > 0: parts.append(f"{missing_mascots} mascots")
|
if missing_mascots > 0: parts.append(f"{missing_mascots} mascots")
|
||||||
print(f"\n ❓ ??? — {' and '.join(parts)} still to discover")
|
_p(f"\n ❓ ??? — {' and '.join(parts)} still to discover")
|
||||||
|
|
||||||
# Signal the Stop hook to re-emit full output as additionalContext (avoids
|
# Write roster output to a file for the Stop hook to deliver as additionalContext.
|
||||||
# Bash tool result truncation). The Stop hook reads and clears this file.
|
# The Stop hook reads roster_output.txt directly — it does NOT re-run this command,
|
||||||
pending = BUDDYMON_DIR / "roster_pending.txt"
|
# avoiding any recursive flag-write loop.
|
||||||
pending.write_text("1")
|
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):
|
def cmd_start(choice: str | None = None):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue