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.
@@ -0,0 +1,3 @@
1
+ """terminalcreature - a terminal pet that evolves with your memory."""
2
+
3
+ __version__ = "2.0.0"
@@ -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())