terminalcreature 2.0.0__py3-none-any.whl
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.
- terminalcreature/__init__.py +3 -0
- terminalcreature/cli.py +647 -0
- terminalcreature/creature.py +142 -0
- terminalcreature/metric.py +145 -0
- terminalcreature/release.py +105 -0
- terminalcreature/render.py +557 -0
- terminalcreature/sprites.py +150 -0
- terminalcreature/state.py +354 -0
- terminalcreature-2.0.0.dist-info/METADATA +502 -0
- terminalcreature-2.0.0.dist-info/RECORD +13 -0
- terminalcreature-2.0.0.dist-info/WHEEL +4 -0
- terminalcreature-2.0.0.dist-info/entry_points.txt +2 -0
- terminalcreature-2.0.0.dist-info/licenses/LICENSE +21 -0
terminalcreature/cli.py
ADDED
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
"""Command line. `render` is what the statusline calls; everything else is human-facing.
|
|
2
|
+
|
|
3
|
+
render must never crash a statusline. On any unexpected error it prints nothing
|
|
4
|
+
and exits 0, because a broken pet should not break the prompt.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from . import creature as creature_mod
|
|
13
|
+
from . import metric, render, sprites
|
|
14
|
+
from . import state as state_mod
|
|
15
|
+
|
|
16
|
+
USAGE = """terminalcreature - a terminal pet that evolves with your memory
|
|
17
|
+
|
|
18
|
+
render one-line statusline segment (what the statusline calls)
|
|
19
|
+
compose "<text>" your statusline text with the creature as a left column
|
|
20
|
+
card full creature card
|
|
21
|
+
new [name] lay a new egg (--replace or --add)
|
|
22
|
+
hatch [--from-zero] open the egg; --from-zero starts at 0 instead of scoring
|
|
23
|
+
[--name <n>] what you've already written. --name names it as it opens
|
|
24
|
+
names two fresh name ideas, for naming an egg before it opens
|
|
25
|
+
focus <name> choose which creature banks new xp
|
|
26
|
+
list the roster
|
|
27
|
+
rename <old> <new>
|
|
28
|
+
retire <name>
|
|
29
|
+
config show settings
|
|
30
|
+
config <key> <val> set one (provider, vault_root, xp_max, density, columns, sprite_height, unicode, border, hidden, update_check)
|
|
31
|
+
hide / show drop the creature from the statusline, or bring it back
|
|
32
|
+
simulate <xp> preview any level without touching your real state
|
|
33
|
+
refresh recompute the xp cache (run in the background by render)
|
|
34
|
+
sources what it can count, and what to do if that's nothing
|
|
35
|
+
doctor [--check] check what terminalcreature can see; --check also asks pypi
|
|
36
|
+
update ask pypi whether there's a newer terminalcreature
|
|
37
|
+
|
|
38
|
+
update and doctor --check are the only commands that go online, and only when
|
|
39
|
+
you run them. Everything else, the statusline included, is offline.
|
|
40
|
+
|
|
41
|
+
provider is claude (stock Claude Code memory), vault (a structured vault) or
|
|
42
|
+
folder (any directory of markdown, set vault_root to point at it).
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load():
|
|
47
|
+
return state_mod.load()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _stdin_text():
|
|
51
|
+
"""Whatever the statusline piped us, or "" when a human is at the keyboard.
|
|
52
|
+
|
|
53
|
+
isatty, or `terminalcreature compose "text"` typed by hand would sit there waiting
|
|
54
|
+
for a statusline payload that is never coming.
|
|
55
|
+
"""
|
|
56
|
+
try:
|
|
57
|
+
if sys.stdin.isatty():
|
|
58
|
+
return ""
|
|
59
|
+
return sys.stdin.read()
|
|
60
|
+
except Exception:
|
|
61
|
+
return ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _session_id(raw):
|
|
65
|
+
"""Claude Code's statusline JSON carries the session id. Only that is read."""
|
|
66
|
+
try:
|
|
67
|
+
return json.loads(raw).get("session_id") or None
|
|
68
|
+
except (ValueError, AttributeError):
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _bank(st, session_id):
|
|
73
|
+
"""Credit new xp, then work out what this session is responsible for."""
|
|
74
|
+
xp, counts = render.current_xp(st, allow_blocking=False)
|
|
75
|
+
dirty = False
|
|
76
|
+
if xp and state_mod.sync(st, xp):
|
|
77
|
+
dirty = True
|
|
78
|
+
c = state_mod.focused(st)
|
|
79
|
+
gain, is_new = state_mod.session_gain(st, session_id, c.get("xp_banked", 0) if c else 0)
|
|
80
|
+
if dirty or is_new:
|
|
81
|
+
state_mod.save(st)
|
|
82
|
+
return xp, counts, gain
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def cmd_render(args):
|
|
86
|
+
try:
|
|
87
|
+
session = _session_id(_stdin_text())
|
|
88
|
+
st = _load()
|
|
89
|
+
if st["settings"].get("hidden"):
|
|
90
|
+
return 0
|
|
91
|
+
xp, counts, gain = _bank(st, session)
|
|
92
|
+
line = render.segment(st, xp, counts, gain=gain)
|
|
93
|
+
if line:
|
|
94
|
+
sys.stdout.write(line)
|
|
95
|
+
except Exception:
|
|
96
|
+
pass
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def cmd_compose(args):
|
|
101
|
+
"""Merge caller-supplied statusline text with the creature as a left column."""
|
|
102
|
+
left = args[0] if args else ""
|
|
103
|
+
try:
|
|
104
|
+
raw = _stdin_text()
|
|
105
|
+
# the statusline passes its text as an argument and its json on stdin.
|
|
106
|
+
# piping the text instead still works, it just has no session to count.
|
|
107
|
+
session = _session_id(raw) if args else None
|
|
108
|
+
if not args:
|
|
109
|
+
left = raw.rstrip("\n")
|
|
110
|
+
st = _load()
|
|
111
|
+
# hidden means the caller's bar passes through untouched, xp still banks on the next visible run
|
|
112
|
+
if st["settings"].get("hidden"):
|
|
113
|
+
sys.stdout.write(left)
|
|
114
|
+
return 0
|
|
115
|
+
xp, counts, gain = _bank(st, session)
|
|
116
|
+
sys.stdout.write(render.compose(st, left, xp, counts, gain=gain))
|
|
117
|
+
except Exception:
|
|
118
|
+
sys.stdout.write(left)
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_refresh(args):
|
|
123
|
+
# measure before taking the snapshot that gets written back. the scan takes
|
|
124
|
+
# as long as the vault is big, and a roster held across it would revert any
|
|
125
|
+
# egg laid or hatched meanwhile
|
|
126
|
+
xp, counts = state_mod.measure_now(state_mod.load()["settings"])
|
|
127
|
+
state_mod.write_cache(xp, counts)
|
|
128
|
+
st = _load()
|
|
129
|
+
event = state_mod.sync(st, xp)
|
|
130
|
+
state_mod.save(st)
|
|
131
|
+
try:
|
|
132
|
+
# opt-in and TTL-gated inside; the xp cache above never waits on it
|
|
133
|
+
from . import release
|
|
134
|
+
release.maybe_refresh_latest(st["settings"])
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
if event:
|
|
138
|
+
print(render.evolution_notice(event, st))
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def cmd_card(args):
|
|
143
|
+
st = _load()
|
|
144
|
+
xp, _ = render.current_xp(st)
|
|
145
|
+
state_mod.sync(st, xp)
|
|
146
|
+
state_mod.save(st)
|
|
147
|
+
print(render.card(st))
|
|
148
|
+
s = st["settings"]
|
|
149
|
+
if not s.get("update_check") and not s.get("update_check_asked"):
|
|
150
|
+
# the one-time offer. default-off would otherwise mean nobody who
|
|
151
|
+
# didn't hatch after this shipped ever learns the check exists.
|
|
152
|
+
# set_setting, not save: this snapshot is as old as the scan above
|
|
153
|
+
print("\nit can check once a day whether a newer terminalcreature exists: one request to")
|
|
154
|
+
print("pypi.org for a version number, nothing about you or your notes goes anywhere.")
|
|
155
|
+
print(" /creature config update_check true")
|
|
156
|
+
state_mod.set_setting("update_check_asked", True)
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_new(args):
|
|
161
|
+
"""Lay a new egg. --replace retires the current buddy, --add keeps it."""
|
|
162
|
+
st = _load()
|
|
163
|
+
name = next((a for a in args if not a.startswith("-")), None)
|
|
164
|
+
cur = state_mod.focused(st)
|
|
165
|
+
first = not st.get("creatures")
|
|
166
|
+
|
|
167
|
+
if cur is None:
|
|
168
|
+
mode = "add"
|
|
169
|
+
elif "--replace" in args:
|
|
170
|
+
mode = "replace"
|
|
171
|
+
elif "--add" in args:
|
|
172
|
+
mode = "add"
|
|
173
|
+
else:
|
|
174
|
+
print("%s is your current buddy. Pick one:" % cur["name"])
|
|
175
|
+
print(" terminalcreature new --replace retire %s and start a new egg" % cur["name"])
|
|
176
|
+
print(" terminalcreature new --add keep %s in the roster, start a new egg" % cur["name"])
|
|
177
|
+
return 1
|
|
178
|
+
|
|
179
|
+
if mode == "add" and cur is not None and "--yes" not in args:
|
|
180
|
+
# no level threshold any more. the tradeoff is the same at 12 as at 99,
|
|
181
|
+
# so state it and let them decide instead of gating on a number
|
|
182
|
+
lvl = metric.level_for(cur["xp_banked"], st["settings"]["xp_max"])
|
|
183
|
+
print("%s is level %d. A new egg starts at 0 and takes focus, so %s holds its level and stops gaining." % (cur["name"], lvl, cur["name"]))
|
|
184
|
+
print("Run: terminalcreature new --add --yes")
|
|
185
|
+
return 1
|
|
186
|
+
|
|
187
|
+
if mode == "replace":
|
|
188
|
+
lvl = metric.level_for(cur["xp_banked"], st["settings"]["xp_max"])
|
|
189
|
+
state_mod.retire(st, cur["id"])
|
|
190
|
+
print("retired %s at Lv%d, %d xp kept. `terminalcreature focus %s` brings it back." % (
|
|
191
|
+
cur["name"], lvl, cur.get("xp_banked", 0), cur["name"]))
|
|
192
|
+
|
|
193
|
+
c = state_mod.create(st, name=name)
|
|
194
|
+
if first:
|
|
195
|
+
# the first egg inherits the memory that already exists, so it opens at
|
|
196
|
+
# your real level. later ones start at zero, that's per-creature banking
|
|
197
|
+
xp, counts = state_mod.measure_now(st["settings"])
|
|
198
|
+
state_mod.write_cache(xp, counts)
|
|
199
|
+
state_mod.sync(st, xp)
|
|
200
|
+
state_mod.save(st)
|
|
201
|
+
print(render.egg_notice(st, c))
|
|
202
|
+
return 0
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cmd_hatch(args):
|
|
206
|
+
"""Open the egg. Reveals whatever level it banked its way to.
|
|
207
|
+
|
|
208
|
+
`--from-zero` opens it at level 0 instead, baselining whatever is already
|
|
209
|
+
written so only notes from here on count.
|
|
210
|
+
"""
|
|
211
|
+
st = _load()
|
|
212
|
+
c = state_mod.focused(st)
|
|
213
|
+
if c is None:
|
|
214
|
+
print("no egg to open. /creature-new lays one.")
|
|
215
|
+
return 1
|
|
216
|
+
if state_mod.is_hatched(c):
|
|
217
|
+
lvl = metric.level_for(c["xp_banked"], st["settings"]["xp_max"])
|
|
218
|
+
print("%s is already out, Lv%d. /creature shows it." % (c["name"], lvl))
|
|
219
|
+
return 1
|
|
220
|
+
|
|
221
|
+
if "--name" in args:
|
|
222
|
+
i = args.index("--name")
|
|
223
|
+
chosen = args[i + 1].strip() if i + 1 < len(args) and not args[i + 1].startswith("-") else ""
|
|
224
|
+
if not chosen:
|
|
225
|
+
print("--name takes the name. try: hatch --name Zephyr")
|
|
226
|
+
return 1
|
|
227
|
+
c["name"] = chosen[:24]
|
|
228
|
+
|
|
229
|
+
# measure instead of reading the cache: the guided flow can set the provider
|
|
230
|
+
# seconds earlier, and a cached count would score the source it replaced
|
|
231
|
+
xp, counts = state_mod.measure_now(st["settings"])
|
|
232
|
+
state_mod.write_cache(xp, counts)
|
|
233
|
+
|
|
234
|
+
from_zero = "--from-zero" in args
|
|
235
|
+
if from_zero:
|
|
236
|
+
# park the high-water mark at everything already written, so none of it
|
|
237
|
+
# gets credited and the next note is the first thing that counts
|
|
238
|
+
st["high_water_xp"] = xp
|
|
239
|
+
c["xp_banked"] = 0
|
|
240
|
+
else:
|
|
241
|
+
state_mod.sync(st, xp)
|
|
242
|
+
state_mod.reveal(st)
|
|
243
|
+
state_mod.save(st)
|
|
244
|
+
# a zero here means there was nothing to count, not that the egg was empty.
|
|
245
|
+
# --from-zero lands on Lv0 too, but that one was chosen and says so itself
|
|
246
|
+
empty = not from_zero and not xp
|
|
247
|
+
print(render.hatch_ceremony(st, c))
|
|
248
|
+
# art=False: the ceremony just showed the sprite and the name. showing the
|
|
249
|
+
# identical creature again ten lines later dilutes the one reveal it gets
|
|
250
|
+
print(render.card(st, xp=0 if from_zero else xp, counts=counts, hungry_note=not empty, art=False))
|
|
251
|
+
if from_zero:
|
|
252
|
+
# the stats still read the live vault, so say why the level doesn't
|
|
253
|
+
print("\n %d xp of existing notes baselined. new ones count from here." % xp)
|
|
254
|
+
elif empty:
|
|
255
|
+
print("\n" + render.empty_hatch_note(st, state_mod.source_status(st["settings"])))
|
|
256
|
+
return 0
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def cmd_names(args):
|
|
260
|
+
"""Two fresh name ideas. Random on purpose: the egg's own seed already has a
|
|
261
|
+
fallback name, and drawing from it here would make every suggestion the same."""
|
|
262
|
+
import uuid
|
|
263
|
+
|
|
264
|
+
for _ in range(2):
|
|
265
|
+
print(creature_mod.suggest_name(uuid.uuid4().hex))
|
|
266
|
+
return 0
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_focus(args):
|
|
270
|
+
if not args:
|
|
271
|
+
print("which one? terminalcreature focus <name>")
|
|
272
|
+
return 1
|
|
273
|
+
st = _load()
|
|
274
|
+
c = state_mod.focus(st, args[0])
|
|
275
|
+
if c is None:
|
|
276
|
+
print("no creature called %s" % args[0])
|
|
277
|
+
return 1
|
|
278
|
+
state_mod.save(st)
|
|
279
|
+
print("focused %s. it banks new xp from here." % c["name"])
|
|
280
|
+
return 0
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def cmd_list(args):
|
|
284
|
+
st = _load()
|
|
285
|
+
if not st.get("creatures"):
|
|
286
|
+
print("no creatures yet. terminalcreature new")
|
|
287
|
+
return 0
|
|
288
|
+
uni = render.unicode_ok(st["settings"])
|
|
289
|
+
for c in st["creatures"]:
|
|
290
|
+
full = creature_mod.hydrate(c)
|
|
291
|
+
lvl = metric.level_for(c["xp_banked"], st["settings"]["xp_max"])
|
|
292
|
+
if state_mod.is_hatched(c):
|
|
293
|
+
idx, stage = metric.stage_for(lvl)
|
|
294
|
+
level_col = "Lv%-4d" % lvl
|
|
295
|
+
# rarity and shiny come off the seed, so an egg must not print them
|
|
296
|
+
desc = full["rarity"] + (" shiny" if full["shiny"] else "")
|
|
297
|
+
else:
|
|
298
|
+
idx, stage, level_col = metric.EGG_SPRITE, "unhatched", "egg "
|
|
299
|
+
desc = ""
|
|
300
|
+
flag = "*" if c["id"] == st.get("focused") else " "
|
|
301
|
+
note = "retired" if c.get("retired_at") else ""
|
|
302
|
+
print(("%s %s %-10s %s %-10s %s %s" % (
|
|
303
|
+
flag, sprites.glyph(idx, uni), c["name"], level_col, stage, desc, note)).rstrip())
|
|
304
|
+
print("\n* = focused (the one gaining xp)")
|
|
305
|
+
return 0
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def cmd_rename(args):
|
|
309
|
+
if len(args) < 2:
|
|
310
|
+
print("terminalcreature rename <old> <new>")
|
|
311
|
+
return 1
|
|
312
|
+
st = _load()
|
|
313
|
+
for c in st.get("creatures", []):
|
|
314
|
+
if c["name"].lower() == args[0].lower():
|
|
315
|
+
c["name"] = args[1]
|
|
316
|
+
state_mod.save(st)
|
|
317
|
+
print("renamed to %s" % args[1])
|
|
318
|
+
return 0
|
|
319
|
+
print("no creature called %s" % args[0])
|
|
320
|
+
return 1
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def cmd_retire(args):
|
|
324
|
+
if not args:
|
|
325
|
+
print("terminalcreature retire <name>")
|
|
326
|
+
return 1
|
|
327
|
+
st = _load()
|
|
328
|
+
c = state_mod.retire(st, args[0])
|
|
329
|
+
if c is None:
|
|
330
|
+
print("no creature called %s" % args[0])
|
|
331
|
+
return 1
|
|
332
|
+
state_mod.save(st)
|
|
333
|
+
# retiring keeps the record. it used to delete, which threw away banked xp
|
|
334
|
+
# with no confirmation and no way back
|
|
335
|
+
print("retired %s, %d xp kept. `terminalcreature focus %s` brings it back." % (
|
|
336
|
+
c["name"], c.get("xp_banked", 0), c["name"]))
|
|
337
|
+
return 0
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def cmd_config(args):
|
|
341
|
+
st = _load()
|
|
342
|
+
if not args:
|
|
343
|
+
# home-relative, so a screenshot of this doesn't carry a username
|
|
344
|
+
shown = dict(st["settings"])
|
|
345
|
+
home = os.path.expanduser("~")
|
|
346
|
+
if shown.get("vault_root", "").startswith(home):
|
|
347
|
+
shown["vault_root"] = "~" + shown["vault_root"][len(home):]
|
|
348
|
+
print(json.dumps(shown, indent=2, sort_keys=True))
|
|
349
|
+
return 0
|
|
350
|
+
if len(args) < 2:
|
|
351
|
+
print("terminalcreature config <key> <value>")
|
|
352
|
+
return 1
|
|
353
|
+
key, raw = args[0], args[1]
|
|
354
|
+
if key not in state_mod.DEFAULT_SETTINGS:
|
|
355
|
+
print("unknown setting %s. known: %s" % (key, ", ".join(sorted(state_mod.DEFAULT_SETTINGS))))
|
|
356
|
+
return 1
|
|
357
|
+
if key == "xp_max":
|
|
358
|
+
try:
|
|
359
|
+
value = int(raw)
|
|
360
|
+
except ValueError:
|
|
361
|
+
print("xp_max takes a number, not %r. try: config xp_max 1500" % raw)
|
|
362
|
+
return 1
|
|
363
|
+
if value < 1:
|
|
364
|
+
print("xp_max must be positive")
|
|
365
|
+
return 1
|
|
366
|
+
elif key in ("unicode", "hidden", "border", "update_check", "update_check_asked"):
|
|
367
|
+
value = raw.lower() in ("1", "true", "yes", "on")
|
|
368
|
+
elif key == "density":
|
|
369
|
+
if raw not in ("compact", "minimal", "full", "sprite", "ruler"):
|
|
370
|
+
print("density must be compact, minimal, full, sprite or ruler")
|
|
371
|
+
return 1
|
|
372
|
+
value = raw
|
|
373
|
+
elif key == "provider":
|
|
374
|
+
if raw not in metric.PROVIDERS:
|
|
375
|
+
print("provider must be one of: %s" % ", ".join(metric.PROVIDERS))
|
|
376
|
+
return 1
|
|
377
|
+
value = raw
|
|
378
|
+
elif key == "vault_root":
|
|
379
|
+
# a relative path would resolve against whatever directory the statusline
|
|
380
|
+
# happens to run in, so it reads as a missing root from anywhere else
|
|
381
|
+
value = raw if raw.startswith("~") else os.path.abspath(os.path.expanduser(raw))
|
|
382
|
+
if not os.path.isdir(os.path.expanduser(value)):
|
|
383
|
+
print("warning: that folder isn't there yet, so nothing will be counted")
|
|
384
|
+
elif key == "weights":
|
|
385
|
+
# a bare string here used to reach metric.measure and crash every read
|
|
386
|
+
print("weights isn't settable from the CLI, edit state.json")
|
|
387
|
+
return 1
|
|
388
|
+
elif key == "sprite_height":
|
|
389
|
+
try:
|
|
390
|
+
value = 3 if int(raw) <= 3 else 5
|
|
391
|
+
except ValueError:
|
|
392
|
+
print("sprite_height takes a number, not %r. it's 3 or 5" % raw)
|
|
393
|
+
return 1
|
|
394
|
+
elif key == "columns":
|
|
395
|
+
try:
|
|
396
|
+
value = int(raw)
|
|
397
|
+
except ValueError:
|
|
398
|
+
print("columns takes a number, not %r. try: config columns 40" % raw)
|
|
399
|
+
return 1
|
|
400
|
+
if value < 0:
|
|
401
|
+
print("columns can't be negative")
|
|
402
|
+
return 1
|
|
403
|
+
else:
|
|
404
|
+
value = raw
|
|
405
|
+
st["settings"][key] = value
|
|
406
|
+
state_mod.save(st, own_settings=True)
|
|
407
|
+
print("%s = %s" % (key, value))
|
|
408
|
+
return 0
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def cmd_simulate(args):
|
|
412
|
+
"""Preview any level without a real vault or touching real state."""
|
|
413
|
+
if not args:
|
|
414
|
+
print("terminalcreature simulate <xp>")
|
|
415
|
+
return 1
|
|
416
|
+
try:
|
|
417
|
+
xp = int(args[0])
|
|
418
|
+
except ValueError:
|
|
419
|
+
print("simulate takes an xp number, not %r. try: simulate 300" % args[0])
|
|
420
|
+
return 1
|
|
421
|
+
st = state_mod.default_state()
|
|
422
|
+
c = state_mod.create(st, name=(args[1] if len(args) > 1 else None))
|
|
423
|
+
state_mod.reveal(st)
|
|
424
|
+
c["xp_banked"] = xp
|
|
425
|
+
p = metric.progress(xp, st["settings"]["xp_max"])
|
|
426
|
+
c["last_stage_seen"] = p["stage_index"]
|
|
427
|
+
# Synthetic counts back-derived from the simulated xp. Reading the real
|
|
428
|
+
# cache here would make a preview quietly report the live vault.
|
|
429
|
+
counts = {"memories": xp // 6, "knowledge": xp // 12, "projects": xp // 30, "sessions": xp // 15, "decisions": xp // 300}
|
|
430
|
+
print(render.card(st, xp=xp, counts=counts))
|
|
431
|
+
print("\nstatusline: [%s]" % render.segment(st, xp, counts))
|
|
432
|
+
return 0
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
PROVIDER_LABEL = {
|
|
436
|
+
"claude": "stock Claude Code memory",
|
|
437
|
+
"vault": "vault layout",
|
|
438
|
+
"folder": "folder of notes",
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
SHIM = "~/.claude/terminalcreature/statusline-terminalcreature.sh"
|
|
442
|
+
USER_SETTINGS = os.path.expanduser("~/.claude/settings.json")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _statusline_command(path):
|
|
446
|
+
"""statusLine.command out of a settings file, or "". Reads, never writes."""
|
|
447
|
+
try:
|
|
448
|
+
with open(path, "r") as f:
|
|
449
|
+
return (json.load(f).get("statusLine") or {}).get("command") or ""
|
|
450
|
+
except (OSError, ValueError, AttributeError):
|
|
451
|
+
return ""
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _project_settings():
|
|
455
|
+
"""The .claude/settings.json this directory would actually use, or None.
|
|
456
|
+
|
|
457
|
+
The working directory first, then the repo root, because a statusline set at
|
|
458
|
+
the root applies to every directory under it and doctor is usually run from
|
|
459
|
+
somewhere deeper.
|
|
460
|
+
"""
|
|
461
|
+
# realpath both sides: /tmp and /home are symlinks on plenty of machines, so
|
|
462
|
+
# comparing what getcwd returns against what ~ expands to misses otherwise
|
|
463
|
+
here = os.path.realpath(os.getcwd())
|
|
464
|
+
home = os.path.realpath(os.path.expanduser("~"))
|
|
465
|
+
candidates, d = [here], here
|
|
466
|
+
while d != home:
|
|
467
|
+
if os.path.isdir(os.path.join(d, ".git")):
|
|
468
|
+
candidates.append(d)
|
|
469
|
+
break
|
|
470
|
+
parent = os.path.dirname(d)
|
|
471
|
+
if parent == d:
|
|
472
|
+
break
|
|
473
|
+
d = parent
|
|
474
|
+
for c in candidates:
|
|
475
|
+
path = os.path.join(c, ".claude", "settings.json")
|
|
476
|
+
# a repo checked out at $HOME would otherwise report the user's own file
|
|
477
|
+
# as a project overriding itself
|
|
478
|
+
if os.path.isfile(path) and os.path.realpath(path) != os.path.realpath(USER_SETTINGS):
|
|
479
|
+
return path
|
|
480
|
+
return None
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _project_override():
|
|
484
|
+
"""Named when a project's own statusline is what runs here. None otherwise.
|
|
485
|
+
|
|
486
|
+
The installer only ever touches ~/.claude/settings.json. A project that sets
|
|
487
|
+
statusLine wins inside that project, so the install is correct, the creature
|
|
488
|
+
is nowhere, and nothing on either side says why.
|
|
489
|
+
"""
|
|
490
|
+
if "statusline-terminalcreature.sh" not in _statusline_command(USER_SETTINGS):
|
|
491
|
+
return None
|
|
492
|
+
path = _project_settings()
|
|
493
|
+
if path is None:
|
|
494
|
+
return None
|
|
495
|
+
command = _statusline_command(path)
|
|
496
|
+
if not command or "statusline-terminalcreature.sh" in command:
|
|
497
|
+
return None
|
|
498
|
+
home = os.path.realpath(os.path.expanduser("~"))
|
|
499
|
+
shown = "~" + path[len(home):] if path.startswith(home) else path
|
|
500
|
+
return "\n".join([
|
|
501
|
+
"this project sets its own statusline in %s, so that one runs here and your buddy doesn't." % shown,
|
|
502
|
+
"the installer only wires ~/.claude/settings.json, and a project's own file wins inside the project.",
|
|
503
|
+
"",
|
|
504
|
+
"wrap theirs, then point the project at the shim:",
|
|
505
|
+
' ./install.sh --statusline "%s"' % command,
|
|
506
|
+
' then in that file: "statusLine": { "type": "command", "command": "%s" }' % SHIM,
|
|
507
|
+
])
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def cmd_doctor(args):
|
|
511
|
+
"""Report what we can see. Counts only, never a path (R12)."""
|
|
512
|
+
st = _load()
|
|
513
|
+
settings = st["settings"]
|
|
514
|
+
status = state_mod.source_status(settings)
|
|
515
|
+
xp, counts = status["xp"], status["counts"]
|
|
516
|
+
print("terminalcreature %s" % __version__)
|
|
517
|
+
print("provider: %s (%s)" % (settings["provider"], PROVIDER_LABEL.get(settings["provider"], "unknown")))
|
|
518
|
+
# the root the user typed, home-relative. R12 covers the memory files we match,
|
|
519
|
+
# and "why is it zero" can't be answered without this
|
|
520
|
+
root = state_mod.sources_for(settings)[0]
|
|
521
|
+
home = os.path.expanduser("~")
|
|
522
|
+
if root.startswith(home):
|
|
523
|
+
root = "~" + root[len(home):]
|
|
524
|
+
print("root: %s (%s)" % (root, "missing" if status["state"] == "missing_root" else "found"))
|
|
525
|
+
for k in sorted(counts):
|
|
526
|
+
print(" %-10s %d" % (k, counts[k]))
|
|
527
|
+
p = metric.progress(xp, settings["xp_max"])
|
|
528
|
+
# the buddy's banked line below is the real level; this one is what the
|
|
529
|
+
# source would feed a fresh egg, and on a broken root they diverge
|
|
530
|
+
print("source xp %d -> level %d (what a new egg would bank)" % (xp, p["level"]))
|
|
531
|
+
# the two diverge whenever a creature was hatched --from-zero, so one line
|
|
532
|
+
# claiming to be both would be wrong for anyone who chose that
|
|
533
|
+
c = state_mod.focused(st)
|
|
534
|
+
if c is not None:
|
|
535
|
+
banked = c.get("xp_banked", 0)
|
|
536
|
+
bp = metric.progress(banked, settings["xp_max"])
|
|
537
|
+
stage = bp["stage"] if state_mod.is_hatched(c) else "egg"
|
|
538
|
+
print("%s banked %d -> level %d (%s)" % (c["name"], banked, bp["level"], stage))
|
|
539
|
+
override = _project_override()
|
|
540
|
+
if override:
|
|
541
|
+
print("\n" + override)
|
|
542
|
+
uc = settings.get("update_check")
|
|
543
|
+
cached = state_mod.read_latest()
|
|
544
|
+
if not uc:
|
|
545
|
+
print("update check: off. `config update_check true` turns on a once-a-day check.")
|
|
546
|
+
elif cached is None:
|
|
547
|
+
print("update check: on, hasn't checked yet")
|
|
548
|
+
elif cached[0]:
|
|
549
|
+
print("update check: on, last checked %s ago, latest is %s" % (_rough_age(cached[1]), cached[0]))
|
|
550
|
+
else:
|
|
551
|
+
print("update check: on, last try %s ago didn't reach pypi" % _rough_age(cached[1]))
|
|
552
|
+
help_text = render.no_source_help(settings, status)
|
|
553
|
+
if help_text:
|
|
554
|
+
print("\n" + help_text)
|
|
555
|
+
if "--check" in args:
|
|
556
|
+
print("\n" + _version_check())
|
|
557
|
+
return 0
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _rough_age(seconds):
|
|
561
|
+
if seconds < 3600:
|
|
562
|
+
return "%dm" % max(1, seconds // 60)
|
|
563
|
+
if seconds < 86400:
|
|
564
|
+
return "%dh" % (seconds // 3600)
|
|
565
|
+
return "%dd" % (seconds // 86400)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _version_check():
|
|
569
|
+
# imported here, not at the top. the socket paths are this, and refresh's
|
|
570
|
+
# opt-in daily check; keeping the imports inside them keeps that legible
|
|
571
|
+
from . import release
|
|
572
|
+
|
|
573
|
+
return release.check()
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def cmd_update(args):
|
|
577
|
+
"""Ask pypi whether there's a newer terminalcreature. One request, then done."""
|
|
578
|
+
print(_version_check())
|
|
579
|
+
return 0
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def cmd_sources(args):
|
|
583
|
+
"""What it can count, and what to do when that's nothing.
|
|
584
|
+
|
|
585
|
+
The installer calls this so a fresh install on a machine with no memory
|
|
586
|
+
system says so on the spot, instead of leaving a level-0 egg and no reason.
|
|
587
|
+
"""
|
|
588
|
+
st = _load()
|
|
589
|
+
status = state_mod.source_status(st["settings"])
|
|
590
|
+
help_text = render.no_source_help(st["settings"], status)
|
|
591
|
+
if help_text:
|
|
592
|
+
print(help_text)
|
|
593
|
+
# nonzero so the installer can branch on the exit code. it used to match
|
|
594
|
+
# on the first word of the success line, which reworded copy would break
|
|
595
|
+
return 1
|
|
596
|
+
print("counting %d xp of memory. /creature shows your buddy." % status["xp"])
|
|
597
|
+
return 0
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _set_hidden(hidden):
|
|
601
|
+
st = _load()
|
|
602
|
+
st["settings"]["hidden"] = hidden
|
|
603
|
+
state_mod.save(st, own_settings=True)
|
|
604
|
+
name = None
|
|
605
|
+
c = state_mod.focused(st)
|
|
606
|
+
if c is not None:
|
|
607
|
+
name = c["name"]
|
|
608
|
+
who = name or "the creature"
|
|
609
|
+
print("%s is %s the statusline" % (who, "hidden from" if hidden else "back in"))
|
|
610
|
+
return 0
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def cmd_hide(args):
|
|
614
|
+
return _set_hidden(True)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def cmd_show(args):
|
|
618
|
+
return _set_hidden(False)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
COMMANDS = {
|
|
622
|
+
"render": cmd_render, "compose": cmd_compose, "refresh": cmd_refresh, "card": cmd_card,
|
|
623
|
+
"new": cmd_new, "hatch": cmd_hatch, "names": cmd_names, "focus": cmd_focus, "list": cmd_list,
|
|
624
|
+
"rename": cmd_rename, "retire": cmd_retire, "config": cmd_config,
|
|
625
|
+
"simulate": cmd_simulate, "doctor": cmd_doctor, "sources": cmd_sources,
|
|
626
|
+
"hide": cmd_hide, "show": cmd_show, "update": cmd_update,
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def main(argv=None):
|
|
631
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
632
|
+
if not argv or argv[0] in ("-h", "--help", "help"):
|
|
633
|
+
print(USAGE)
|
|
634
|
+
return 0
|
|
635
|
+
fn = COMMANDS.get(argv[0])
|
|
636
|
+
if fn is None:
|
|
637
|
+
# the full usage after a typo is a wall. one guess or one pointer
|
|
638
|
+
import difflib
|
|
639
|
+
close = difflib.get_close_matches(argv[0], COMMANDS, n=1)
|
|
640
|
+
hint = "did you mean %s?" % close[0] if close else "terminalcreature -h lists what there is"
|
|
641
|
+
print("unknown command %s. %s" % (argv[0], hint))
|
|
642
|
+
return 1
|
|
643
|
+
return fn(argv[1:])
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
if __name__ == "__main__":
|
|
647
|
+
sys.exit(main())
|