@rm0nroe/coach-claw 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- package/skills/config/SKILL.md +149 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Switch Coach control from the npm CLI to the Claude Code plugin.
|
|
2
|
+
|
|
3
|
+
Removes CLI-installed hook entries from ~/.claude/settings.json so the
|
|
4
|
+
plugin's hooks take over. Also clears a CLI-installed statusLine entry
|
|
5
|
+
if present (the plugin will reinstall its own on the next session via
|
|
6
|
+
statusline_self_patch).
|
|
7
|
+
|
|
8
|
+
Use case: a user has both distributions installed and the plugin's
|
|
9
|
+
coexistence_check is currently deferring to the CLI (.plugin-deferred
|
|
10
|
+
marker present). They want to flip control. Running
|
|
11
|
+
/coach-claw:switch is the supported way to do that without manually
|
|
12
|
+
editing settings.json.
|
|
13
|
+
|
|
14
|
+
The npm CLI's installed Python files (~/.claude/hooks/coach-*.py) are
|
|
15
|
+
NOT touched by this script — they remain on disk but are no longer
|
|
16
|
+
referenced from settings.json. Run `npx @rm0nroe/coach-claw uninstall`
|
|
17
|
+
separately for full CLI cleanup.
|
|
18
|
+
|
|
19
|
+
Atomic write under flock so concurrent /plugin install or other
|
|
20
|
+
settings mutations don't race.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import fcntl
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import shlex
|
|
29
|
+
import sys
|
|
30
|
+
import tempfile
|
|
31
|
+
from datetime import datetime, timezone
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
35
|
+
from coach_paths import resolve_coach_dir # noqa: E402
|
|
36
|
+
|
|
37
|
+
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
|
|
38
|
+
|
|
39
|
+
HOOK_SCRIPT_NAMES = ("coach-session-start.py", "coach-user-prompt.py")
|
|
40
|
+
COACH_STATUSLINE_MARKERS = (
|
|
41
|
+
"default-statusline-command.sh",
|
|
42
|
+
"default_statusline.py",
|
|
43
|
+
)
|
|
44
|
+
# Substring fragments identifying the wrap-shape statusLine entries.
|
|
45
|
+
CLI_WRAP_TRAMPOLINE = "default-statusline-wrap-command.sh"
|
|
46
|
+
PLUGIN_WRAP_SCRIPT = "statusline_wrap.py"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _is_cli_hook(cmd: str, plugin_root: str) -> bool:
|
|
50
|
+
if not any(name in cmd for name in HOOK_SCRIPT_NAMES):
|
|
51
|
+
return False
|
|
52
|
+
if plugin_root and plugin_root in cmd:
|
|
53
|
+
return False
|
|
54
|
+
return True
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _is_cli_statusline(cmd: str, plugin_root: str) -> bool:
|
|
58
|
+
"""CLI's statusLine uses default-statusline-command.sh; plugin's
|
|
59
|
+
uses default_statusline.py via bootstrap.sh. Removing the CLI's
|
|
60
|
+
leaves the plugin's self-patch to reinstall on next SessionStart."""
|
|
61
|
+
if "default-statusline-command.sh" not in cmd:
|
|
62
|
+
return False
|
|
63
|
+
if plugin_root and plugin_root in cmd:
|
|
64
|
+
return False
|
|
65
|
+
return True
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _strip_cli_hooks(data: dict, plugin_root: str) -> int:
|
|
69
|
+
"""Remove CLI-pattern hook entries in-place. Returns count removed."""
|
|
70
|
+
removed = 0
|
|
71
|
+
hooks = data.get("hooks")
|
|
72
|
+
if not isinstance(hooks, dict):
|
|
73
|
+
return 0
|
|
74
|
+
for event in ("SessionStart", "UserPromptSubmit"):
|
|
75
|
+
groups = hooks.get(event)
|
|
76
|
+
if not isinstance(groups, list):
|
|
77
|
+
continue
|
|
78
|
+
new_groups = []
|
|
79
|
+
for grp in groups:
|
|
80
|
+
if not isinstance(grp, dict):
|
|
81
|
+
new_groups.append(grp)
|
|
82
|
+
continue
|
|
83
|
+
inner = grp.get("hooks")
|
|
84
|
+
if not isinstance(inner, list):
|
|
85
|
+
new_groups.append(grp)
|
|
86
|
+
continue
|
|
87
|
+
new_inner = []
|
|
88
|
+
for h in inner:
|
|
89
|
+
if isinstance(h, dict) and _is_cli_hook(str(h.get("command", "")), plugin_root):
|
|
90
|
+
removed += 1
|
|
91
|
+
continue
|
|
92
|
+
new_inner.append(h)
|
|
93
|
+
if new_inner:
|
|
94
|
+
new_groups.append({**grp, "hooks": new_inner})
|
|
95
|
+
elif grp.get("hooks"):
|
|
96
|
+
# Group emptied by removals — drop it entirely.
|
|
97
|
+
pass
|
|
98
|
+
else:
|
|
99
|
+
new_groups.append(grp)
|
|
100
|
+
if new_groups:
|
|
101
|
+
hooks[event] = new_groups
|
|
102
|
+
else:
|
|
103
|
+
del hooks[event]
|
|
104
|
+
return removed
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _strip_cli_statusline(data: dict, plugin_root: str) -> bool:
|
|
108
|
+
"""Remove the CLI's default statusLine entry if present. Returns True
|
|
109
|
+
if removed, False if nothing to do or it was someone else's."""
|
|
110
|
+
sl = data.get("statusLine")
|
|
111
|
+
if not isinstance(sl, dict):
|
|
112
|
+
return False
|
|
113
|
+
if _is_cli_statusline(str(sl.get("command", "")), plugin_root):
|
|
114
|
+
del data["statusLine"]
|
|
115
|
+
return True
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _rewrite_cli_wrap_to_plugin(data: dict, plugin_root: str) -> bool:
|
|
120
|
+
"""If the statusLine is the CLI wrap-shape trampoline AND we have a
|
|
121
|
+
plugin_root to rewrite to, replace it with the plugin wrap shape.
|
|
122
|
+
Preserves `.statusline-wrap.json` (operate on settings.json only).
|
|
123
|
+
|
|
124
|
+
Returns True when rewritten, False when nothing to do (no statusLine,
|
|
125
|
+
not a wrap shape, or already plugin shape).
|
|
126
|
+
"""
|
|
127
|
+
if not plugin_root:
|
|
128
|
+
return False
|
|
129
|
+
sl = data.get("statusLine")
|
|
130
|
+
if not isinstance(sl, dict):
|
|
131
|
+
return False
|
|
132
|
+
cmd = str(sl.get("command", ""))
|
|
133
|
+
# Skip if not the CLI wrap trampoline.
|
|
134
|
+
if CLI_WRAP_TRAMPOLINE not in cmd:
|
|
135
|
+
return False
|
|
136
|
+
# Already pointing at the plugin's bin/ — leave alone.
|
|
137
|
+
if PLUGIN_WRAP_SCRIPT in cmd and plugin_root in cmd:
|
|
138
|
+
return False
|
|
139
|
+
pr = Path(plugin_root)
|
|
140
|
+
bootstrap = shlex.quote(str(pr / "bin" / "bootstrap.sh"))
|
|
141
|
+
wrap_py = shlex.quote(str(pr / "bin" / "statusline_wrap.py"))
|
|
142
|
+
new_cmd = f"{bootstrap} {wrap_py}"
|
|
143
|
+
data["statusLine"] = {"type": "command", "command": new_cmd}
|
|
144
|
+
return True
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _atomic_write(path: Path, payload: dict) -> None:
|
|
148
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
149
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
150
|
+
with open(lock_path, "w") as lock_fh:
|
|
151
|
+
try:
|
|
152
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
fd, tmp = tempfile.mkstemp(
|
|
156
|
+
prefix="." + path.name + ".",
|
|
157
|
+
suffix=".tmp",
|
|
158
|
+
dir=str(path.parent),
|
|
159
|
+
)
|
|
160
|
+
try:
|
|
161
|
+
with os.fdopen(fd, "w") as fh:
|
|
162
|
+
json.dump(payload, fh, indent=2)
|
|
163
|
+
fh.flush()
|
|
164
|
+
os.fsync(fh.fileno())
|
|
165
|
+
os.replace(tmp, path)
|
|
166
|
+
except Exception:
|
|
167
|
+
try:
|
|
168
|
+
os.unlink(tmp)
|
|
169
|
+
except Exception:
|
|
170
|
+
pass
|
|
171
|
+
raise
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _write_marker(coach_dir: Path) -> None:
|
|
175
|
+
"""Persist a marker so `/coach-claw:doctor` and the deferred-state
|
|
176
|
+
UI know the user explicitly switched."""
|
|
177
|
+
try:
|
|
178
|
+
coach_dir.mkdir(parents=True, exist_ok=True)
|
|
179
|
+
marker = coach_dir / ".cli-uninstalled-by-plugin"
|
|
180
|
+
marker.write_text(json.dumps({
|
|
181
|
+
"switched_at": datetime.now(timezone.utc).isoformat(),
|
|
182
|
+
}))
|
|
183
|
+
# Clear any stale defer marker since CLI is gone now.
|
|
184
|
+
defer = coach_dir / ".plugin-deferred"
|
|
185
|
+
if defer.exists():
|
|
186
|
+
defer.unlink()
|
|
187
|
+
except Exception:
|
|
188
|
+
pass
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: list[str] | None = None) -> int:
|
|
192
|
+
parser = argparse.ArgumentParser(description="Switch Coach control to the plugin.")
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--settings",
|
|
195
|
+
default=str(SETTINGS_PATH),
|
|
196
|
+
help="Path to settings.json (default: ~/.claude/settings.json)",
|
|
197
|
+
)
|
|
198
|
+
parser.add_argument(
|
|
199
|
+
"--dry-run",
|
|
200
|
+
action="store_true",
|
|
201
|
+
help="Report what would change without writing.",
|
|
202
|
+
)
|
|
203
|
+
args = parser.parse_args(argv)
|
|
204
|
+
|
|
205
|
+
settings_path = Path(args.settings)
|
|
206
|
+
if not settings_path.exists():
|
|
207
|
+
print(f"settings.json not found at {settings_path}", file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
try:
|
|
210
|
+
data = json.loads(settings_path.read_text())
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
print(f"settings.json is not valid JSON: {exc}", file=sys.stderr)
|
|
213
|
+
return 2
|
|
214
|
+
if not isinstance(data, dict):
|
|
215
|
+
print("settings.json is not a JSON object", file=sys.stderr)
|
|
216
|
+
return 2
|
|
217
|
+
|
|
218
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
|
|
219
|
+
removed_hooks = _strip_cli_hooks(data, plugin_root)
|
|
220
|
+
rewritten_wrap = _rewrite_cli_wrap_to_plugin(data, plugin_root)
|
|
221
|
+
# Wrap rewrite already mutated statusLine — don't also strip it.
|
|
222
|
+
removed_statusline = (
|
|
223
|
+
False if rewritten_wrap else _strip_cli_statusline(data, plugin_root)
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
if removed_hooks == 0 and not removed_statusline and not rewritten_wrap:
|
|
227
|
+
print("No CLI hooks or statusLine found in settings.json — nothing to do.")
|
|
228
|
+
return 0
|
|
229
|
+
|
|
230
|
+
if args.dry_run:
|
|
231
|
+
print(f"Would remove {removed_hooks} CLI hook entries.")
|
|
232
|
+
if removed_statusline:
|
|
233
|
+
print("Would remove CLI statusLine entry.")
|
|
234
|
+
if rewritten_wrap:
|
|
235
|
+
print("Would rewrite CLI wrap-statusLine to plugin wrap shape.")
|
|
236
|
+
return 0
|
|
237
|
+
|
|
238
|
+
_atomic_write(settings_path, data)
|
|
239
|
+
_write_marker(resolve_coach_dir())
|
|
240
|
+
|
|
241
|
+
print(f"Removed {removed_hooks} CLI hook entries.")
|
|
242
|
+
if removed_statusline:
|
|
243
|
+
print("Removed CLI statusLine entry. Plugin will reinstall its own next session.")
|
|
244
|
+
if rewritten_wrap:
|
|
245
|
+
print("Rewrote CLI wrap-statusLine to plugin wrap shape (saved original preserved).")
|
|
246
|
+
print()
|
|
247
|
+
print("Plugin is now in charge. The CLI's installed files in")
|
|
248
|
+
print(" ~/.claude/hooks/coach-*.py and ~/.claude/coach/")
|
|
249
|
+
print("are untouched. Run `npx @rm0nroe/coach-claw uninstall` for")
|
|
250
|
+
print("full CLI removal (optional — coach state in ~/.claude/coach/")
|
|
251
|
+
print("is shared between distributions and stays put either way).")
|
|
252
|
+
return 0
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
if __name__ == "__main__":
|
|
256
|
+
sys.exit(main())
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
"""Level-name ladders for the rank/level display.
|
|
2
|
+
|
|
3
|
+
Each theme is a 50-element list — one name per level, ordered L1 → L50.
|
|
4
|
+
The progression arc is the theme's identity: craft (technical mastery),
|
|
5
|
+
forge (blacksmithing), cosmic (stellar/spacetime), ocean (lobster-mascot
|
|
6
|
+
marine depth), plus eight pop-culture-inspired ladders.
|
|
7
|
+
|
|
8
|
+
Adding a theme: append a new key to THEMES with exactly 50 unique
|
|
9
|
+
single-word entries. The test suite (test_themes.py) enforces both.
|
|
10
|
+
|
|
11
|
+
The selected theme is read from `~/.claude/coach/.user_config.json`
|
|
12
|
+
via `user_config.get_theme()`. Default = "craft" so existing installs
|
|
13
|
+
without a config file are unchanged.
|
|
14
|
+
|
|
15
|
+
# Brand safety — pop-culture themes
|
|
16
|
+
|
|
17
|
+
The eight pop-culture-inspired themes (skyrim, marvel, dc, finalfantasy,
|
|
18
|
+
military, lotr, starwars, hacker) are FAN-INSPIRED and use only:
|
|
19
|
+
|
|
20
|
+
- Public-domain mythology (Norse, Greek, Mesopotamian, Hindu, Biblical, etc.)
|
|
21
|
+
- Real-world historical figures and titles (Caesar, Khan, Shogun, Centurion)
|
|
22
|
+
- Genre-generic terminology that pre-dates or transcends any single
|
|
23
|
+
franchise (Knight, Wizard, Mage, Apprentice, Master, Sentinel, etc.)
|
|
24
|
+
- Common English compound words (Greybeard, Highking, Worldwarden)
|
|
25
|
+
|
|
26
|
+
Specifically EXCLUDED from every ladder:
|
|
27
|
+
- Names invented by a franchise (Dovahkiin, Padawan, Vibranium, Maiar)
|
|
28
|
+
- Named characters from any franchise
|
|
29
|
+
- Trademarked group / organization names (The Avengers, Justice League,
|
|
30
|
+
Jedi Order, Lantern Corps as such)
|
|
31
|
+
|
|
32
|
+
Coach Claw is not affiliated with or endorsed by any franchise owner.
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
# L1-L8 preserved from the original ladder for backwards-compat with
|
|
37
|
+
# existing XP totals. L9-L50 follow the technical-mastery arc:
|
|
38
|
+
# recognized excellence → mastery → cosmic → transcendent.
|
|
39
|
+
THEME_CRAFT = [
|
|
40
|
+
"Drafter", "Iterator", "Builder", "Shipper", "Craftsman",
|
|
41
|
+
"Architect", "Virtuoso", "Sensei", "Luminary", "Legend",
|
|
42
|
+
"Mythic", "Ascendant", "Pioneer", "Vanguard", "Savant",
|
|
43
|
+
"Prodigy", "Visionary", "Oracle", "Sage", "Paragon",
|
|
44
|
+
"Archmage", "Grandmaster", "Elder", "Progenitor", "Sovereign",
|
|
45
|
+
"Titan", "Colossus", "Zenith", "Overmind", "Transcendent",
|
|
46
|
+
"Celestial", "Cosmic", "Stellar", "Nebula", "Supernova",
|
|
47
|
+
"Singularity", "Primordial", "Aether", "Eldritch", "Eternal",
|
|
48
|
+
"Immortal", "Divine", "Demiurge", "Alpha", "Omega",
|
|
49
|
+
"Apex", "Ultima", "Infinite", "Genesis", "Origin",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
# Blacksmithing arc — apprentice → master smith → primal forge.
|
|
53
|
+
THEME_FORGE = [
|
|
54
|
+
"Apprentice", "Striker", "Smith", "Forger", "Hammerhand",
|
|
55
|
+
"Bladesmith", "Toolsmith", "Mastersmith", "Forgemaster", "Anvilkeeper",
|
|
56
|
+
"Furnacekeeper", "Quenchmaster", "Edgewright", "Steelwright", "Ironwright",
|
|
57
|
+
"Bellowsmith", "Crucible", "Tempersworn", "Hardener", "Foundrylord",
|
|
58
|
+
"Smelter", "Blastsmith", "Damascus", "Foldweaver", "Patternsmith",
|
|
59
|
+
"Runesmith", "Soulforger", "Starsmith", "Voidwright", "Worldforger",
|
|
60
|
+
"Spiritsmith", "Lightsmith", "Flamewarden", "Inferno", "Cinder",
|
|
61
|
+
"Ember", "Glow", "Furnacecore", "Magmaheart", "Sunsmith",
|
|
62
|
+
"Forgekin", "Coresoul", "Eternalflame", "Worldhammer", "Anvilheart",
|
|
63
|
+
"Pillar", "Bedrock", "Forgefather", "Primalforge", "Genesis",
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
# Cosmological arc — spark of matter → galaxy → cosmic inflation.
|
|
67
|
+
THEME_COSMIC = [
|
|
68
|
+
"Spark", "Mote", "Particle", "Atom", "Element",
|
|
69
|
+
"Crystal", "Mineral", "Aerolith", "Asteroid", "Meteor",
|
|
70
|
+
"Comet", "Moon", "Planet", "Star", "Pulsar",
|
|
71
|
+
"Quasar", "Magnetar", "Nebula", "Nova", "Supernova",
|
|
72
|
+
"Hypernova", "Galaxy", "Constellation", "Cluster", "Spiral",
|
|
73
|
+
"Filament", "Wormhole", "Horizon", "Singularity", "Universe",
|
|
74
|
+
"Multiverse", "Cosmosphere", "Plenum", "Continuum", "Aether",
|
|
75
|
+
"Ether", "Void", "Abyssum", "Eonkeeper", "Timeweaver",
|
|
76
|
+
"Spaceforger", "Lightspeed", "Causal", "Quantum", "Primordial",
|
|
77
|
+
"Genesis", "Inflation", "Eternity", "Infinity", "Origin",
|
|
78
|
+
]
|
|
79
|
+
|
|
80
|
+
# Marine-depth arc — lobster mascot tribute. Hatchling → leviathan → origin.
|
|
81
|
+
THEME_OCEAN = [
|
|
82
|
+
"Hatchling", "Larva", "Wriggler", "Postlarva", "Juvenile",
|
|
83
|
+
"Forager", "Burrower", "Reefer", "Hunter", "Patroller",
|
|
84
|
+
"Stalker", "Predator", "Tidewalker", "Currentrider", "Wavecrest",
|
|
85
|
+
"Reefking", "Tidemaster", "Deepswimmer", "Sandstrider", "Coralweaver",
|
|
86
|
+
"Stormbringer", "Squallchaser", "Tidelord", "Reeflord", "Abyssal",
|
|
87
|
+
"Deepkeeper", "Trenchwalker", "Voidswimmer", "Leviathan", "Kraken",
|
|
88
|
+
"Reefshaper", "Oceanic", "Saltkin", "Pearlborn", "Deepforger",
|
|
89
|
+
"Mariner", "Tideborn", "Foamprince", "Wavekeeper", "Tidesage",
|
|
90
|
+
"Cosmic", "Primordial", "Oceanmind", "Worldsea", "Eternalshore",
|
|
91
|
+
"Tideborne", "Currentlord", "Genesis", "Source", "Origin",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
# === POP-CULTURE THEMES ===================================================
|
|
95
|
+
# All entries below are public-domain mythology, real historical titles,
|
|
96
|
+
# or genre-generic terminology. No franchise-coined neologisms or named
|
|
97
|
+
# characters. See module docstring "Brand safety" for the policy.
|
|
98
|
+
|
|
99
|
+
# Skyrim-inspired — Norse / Anglo-Saxon hierarchy + generic fantasy.
|
|
100
|
+
# Bandit → Whelp/Initiate paths → Companion / Listener / Greybeard tier
|
|
101
|
+
# → Wyrmkin → Norse pantheon. No Bethesda-coined words (no Dovahkiin,
|
|
102
|
+
# Daedra, Aedra, Talos, Alduin, etc.).
|
|
103
|
+
THEME_SKYRIM = [
|
|
104
|
+
"Pauper", "Citizen", "Footpad", "Bandit", "Adventurer",
|
|
105
|
+
"Whelp", "Initiate", "Pupil", "Apprentice", "Sellsword",
|
|
106
|
+
"Adept", "Skald", "Drengr", "Berserker", "Conjurer",
|
|
107
|
+
"Burglar", "Mercenary", "Slayer", "Wizard", "Centurion",
|
|
108
|
+
"Praefect", "Warlock", "Guildmaster", "Master", "Legate",
|
|
109
|
+
"Archmage", "Companion", "Speaker", "General", "Blade",
|
|
110
|
+
"Harbinger", "Listener", "Greybeard", "Tongue", "Thane",
|
|
111
|
+
"Jarl", "Stormcaller", "Konung", "Champion", "Voidwalker",
|
|
112
|
+
"Wyrmkin", "Wyrmblood", "Dragonkin", "Highking", "Highlord",
|
|
113
|
+
"Worldwarden", "Vanir", "Aesir", "Asgardian", "Anu",
|
|
114
|
+
]
|
|
115
|
+
|
|
116
|
+
# Marvel-inspired — comics power-tier classification, no character names
|
|
117
|
+
# or trademarked group names. Civilian → Hero → cosmic → abstract.
|
|
118
|
+
THEME_MARVEL = [
|
|
119
|
+
"Civilian", "Witness", "Bystander", "Cadet", "Operative",
|
|
120
|
+
"Agent", "Vigilante", "Sidekick", "Hero", "Defender",
|
|
121
|
+
"Mutant", "Mystic", "Sorcerer", "Specialist", "Captain",
|
|
122
|
+
"Knight", "Crusader", "Sentinel", "Watchman", "Champion",
|
|
123
|
+
"Speedster", "Telepath", "Telekinetic", "Pyromancer", "Cryomancer",
|
|
124
|
+
"Channeler", "Berserker", "Behemoth", "Phoenix", "Avatar",
|
|
125
|
+
"Cosmic", "Celestial", "Eternal", "Galactic", "Stellar",
|
|
126
|
+
"Universal", "Multiversal", "Dimensional", "Astral", "Beyond",
|
|
127
|
+
"Eternity", "Infinity", "Tribunal", "Allfather", "Existence",
|
|
128
|
+
"Sovereign", "Above", "Origin", "Apex", "Source",
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
# DC-inspired — vigilante / detective angle, Justice / Trinity / New Gods
|
|
132
|
+
# cosmology. Differentiated from Marvel by Detective+Justicer flavor and
|
|
133
|
+
# Highfather/Spectre/Presence top tiers.
|
|
134
|
+
THEME_DC = [
|
|
135
|
+
"Civilian", "Bystander", "Witness", "Patrolman", "Detective",
|
|
136
|
+
"Operative", "Agent", "Vigilante", "Sidekick", "Hero",
|
|
137
|
+
"Crusader", "Justicer", "Defender", "Watchman", "Sentinel",
|
|
138
|
+
"Captain", "Knight", "Centurion", "Champion", "Specialist",
|
|
139
|
+
"Speedster", "Telepath", "Sorcerer", "Mystic", "Lantern",
|
|
140
|
+
"Wonder", "Founder", "Council", "Trinity", "Avatar",
|
|
141
|
+
"Demigod", "Eternal", "Stellar", "Cosmic", "Galactic",
|
|
142
|
+
"Universal", "Multiversal", "Beyond", "Aspect", "Spirit",
|
|
143
|
+
"Spectre", "Phantom", "Highfather", "Endless", "Highest",
|
|
144
|
+
"Sovereign", "Crisis", "Presence", "Origin", "Source",
|
|
145
|
+
]
|
|
146
|
+
|
|
147
|
+
# Final Fantasy-inspired — generic JRPG job classes + summon-type terms.
|
|
148
|
+
# Specific FF characters / Onion Knight / Cetra / Esper-as-named-summon
|
|
149
|
+
# excluded; mythological summon types (Aeon, Eidolon, Primal) are public
|
|
150
|
+
# domain (Greek "eidolon", broader use), so retained.
|
|
151
|
+
THEME_FINALFANTASY = [
|
|
152
|
+
"Squire", "Page", "Cadet", "Apprentice", "Initiate",
|
|
153
|
+
"Adept", "Knight", "Warrior", "Crusader", "Paladin",
|
|
154
|
+
"Berserker", "Monk", "Lancer", "Dragoon", "Samurai",
|
|
155
|
+
"Ninja", "Bard", "Dancer", "Geomancer", "Cleric",
|
|
156
|
+
"Warlock", "Spellsword", "Mystic", "Scholar", "Astrologer",
|
|
157
|
+
"Necromancer", "Alchemist", "Engineer", "Reaper", "Viper",
|
|
158
|
+
"Mediator", "Channeler", "Summoner", "Hero", "Champion",
|
|
159
|
+
"Lightbringer", "Crystal", "Aeon", "Eidolon", "Esper",
|
|
160
|
+
"Familiar", "Primal", "Avatar", "Sovereign", "Worldwarden",
|
|
161
|
+
"Skywarden", "Source", "Origin", "Singularity", "Genesis",
|
|
162
|
+
]
|
|
163
|
+
|
|
164
|
+
# Military hierarchy — US enlisted/officer ranks + Roman + Asian + mythic
|
|
165
|
+
# war archetypes. All real historical titles (Caesar, Khan, Shogun) or
|
|
166
|
+
# generic English military terms.
|
|
167
|
+
THEME_MILITARY = [
|
|
168
|
+
"Recruit", "Private", "Specialist", "Corporal", "Sergeant",
|
|
169
|
+
"Staffsergeant", "Mastersergeant", "Sergeantmajor", "Ensign", "Cadet",
|
|
170
|
+
"Lieutenant", "Captain", "Major", "Colonel", "Brigadier",
|
|
171
|
+
"General", "Fieldmarshal", "Commander", "Commodore", "Admiral",
|
|
172
|
+
"Viceadmiral", "Fleetadmiral", "Marshal", "Airmarshal", "Highmarshal",
|
|
173
|
+
"Ranger", "Beret", "Seal", "Marine", "Paratrooper",
|
|
174
|
+
"Sniper", "Operative", "Aviator", "Pilot", "Centurion",
|
|
175
|
+
"Optio", "Tribune", "Legate", "Praetor", "Consul",
|
|
176
|
+
"Imperator", "Caesar", "Augustus", "Samurai", "Daimyo",
|
|
177
|
+
"Khan", "Shogun", "Conqueror", "Generalissimo", "Polemarch",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# LOTR-inspired — Anglo-Saxon / generic medieval high-fantasy. None of:
|
|
181
|
+
# Hobbit, Maiar, Valar, Eru, Numenor, Mordor, Shire, Gondor, Rohan, or
|
|
182
|
+
# named characters. "Halfling" is pre-Tolkien (in 1860s English use).
|
|
183
|
+
THEME_LOTR = [
|
|
184
|
+
"Pauper", "Cottar", "Wanderer", "Squire", "Apprentice",
|
|
185
|
+
"Halfling", "Footsoldier", "Pikeman", "Outrider", "Greenrider",
|
|
186
|
+
"Knight", "Esquire", "Ranger", "Scout", "Hunter",
|
|
187
|
+
"Marshal", "Captain", "Bannerman", "Reeve", "Thegn",
|
|
188
|
+
"Champion", "Crusader", "Sentinel", "Warden", "Guardian",
|
|
189
|
+
"Watchman", "Liegelord", "Lord", "Highlord", "Steward",
|
|
190
|
+
"Regent", "King", "Highking", "Sage", "Loremaster",
|
|
191
|
+
"Wizard", "Conjurer", "Mage", "Archmage", "Highmage",
|
|
192
|
+
"Mystic", "Oracle", "Visionary", "Wraith", "Spectre",
|
|
193
|
+
"Shade", "Avatar", "Forefather", "Source", "Origin",
|
|
194
|
+
]
|
|
195
|
+
|
|
196
|
+
# Star Wars-inspired — generic monk-knight + Light/Dark hierarchy. None
|
|
197
|
+
# of: Jedi, Sith, Padawan, Lightsaber, Force, Mandalorian, or named
|
|
198
|
+
# characters. Initiate → Apprentice → Knight → Master → Lord progression
|
|
199
|
+
# is broadly monastic, predates SW.
|
|
200
|
+
THEME_STARWARS = [
|
|
201
|
+
"Civilian", "Cadet", "Recruit", "Initiate", "Acolyte",
|
|
202
|
+
"Apprentice", "Disciple", "Adept", "Pupil", "Trainee",
|
|
203
|
+
"Squire", "Knight", "Crusader", "Champion", "Hunter",
|
|
204
|
+
"Tracker", "Warrior", "Sentinel", "Guardian", "Defender",
|
|
205
|
+
"Captain", "Master", "Council", "Sovereign", "Lord",
|
|
206
|
+
"Highlord", "Warlord", "Darklord", "Commander", "Chancellor",
|
|
207
|
+
"Emperor", "Mystic", "Sage", "Seer", "Oracle",
|
|
208
|
+
"Shadowmaster", "Lightbearer", "Voidwalker", "Starwarden", "Skywarden",
|
|
209
|
+
"Voidlord", "Galactic", "Stellar", "Cosmic", "Eternal",
|
|
210
|
+
"Astral", "Celestial", "Origin", "Source", "Above",
|
|
211
|
+
]
|
|
212
|
+
|
|
213
|
+
# Hacker / dev-culture — mainstream dev hierarchy + system-internals
|
|
214
|
+
# folklore. No company names, no trademarked product names. "Linux",
|
|
215
|
+
# "Unix", "Bell Labs", "Knuth" intentionally absent — using them as
|
|
216
|
+
# rank names is gimmicky and risks association.
|
|
217
|
+
THEME_HACKER = [
|
|
218
|
+
"Lurker", "Reader", "Newbie", "Scriptkiddie", "Tinkerer",
|
|
219
|
+
"Coder", "Programmer", "Hacker", "Junior", "Engineer",
|
|
220
|
+
"Developer", "Senior", "Lead", "Architect", "Principal",
|
|
221
|
+
"Distinguished", "Fellow", "Maintainer", "Reviewer", "Mentor",
|
|
222
|
+
"Wizard", "Sorcerer", "Guru", "Sensei", "Sage",
|
|
223
|
+
"Oracle", "Veteran", "Greybeard", "Founder", "Pioneer",
|
|
224
|
+
"Daemon", "Compiler", "Linker", "Kernel", "Hypervisor",
|
|
225
|
+
"Bootloader", "Firmware", "Microservice", "Distributed", "Quantum",
|
|
226
|
+
"Specter", "Phantom", "Ghost", "Demon", "Druid",
|
|
227
|
+
"Phreaker", "Legend", "Source", "Genesis", "Singularity",
|
|
228
|
+
]
|
|
229
|
+
|
|
230
|
+
THEMES: dict[str, list[str]] = {
|
|
231
|
+
"craft": THEME_CRAFT,
|
|
232
|
+
"forge": THEME_FORGE,
|
|
233
|
+
"cosmic": THEME_COSMIC,
|
|
234
|
+
"ocean": THEME_OCEAN,
|
|
235
|
+
"skyrim": THEME_SKYRIM,
|
|
236
|
+
"marvel": THEME_MARVEL,
|
|
237
|
+
"dc": THEME_DC,
|
|
238
|
+
"finalfantasy": THEME_FINALFANTASY,
|
|
239
|
+
"military": THEME_MILITARY,
|
|
240
|
+
"lotr": THEME_LOTR,
|
|
241
|
+
"starwars": THEME_STARWARS,
|
|
242
|
+
"hacker": THEME_HACKER,
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
DEFAULT_THEME = "craft"
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def get_ladder(theme: str) -> list[str]:
|
|
249
|
+
"""Return the 50-element name list for `theme`. Falls back to default
|
|
250
|
+
on unknown themes — the caller never sees a KeyError."""
|
|
251
|
+
return THEMES.get(theme, THEMES[DEFAULT_THEME])
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def list_themes() -> list[str]:
|
|
255
|
+
"""Stable list of available theme keys, default first for `/config`."""
|
|
256
|
+
return [DEFAULT_THEME] + sorted(k for k in THEMES if k != DEFAULT_THEME)
|