# TOML Help System Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Replace hardcoded help text with TOML-driven help topics in `content/help/`, and add `@help` admin commands for creating, editing, and removing help topics in-game. **Architecture:** Help topics are TOML files in `content/help/` loaded at startup into a `_help_topics` dict. The `help` command checks topics before falling back to command detail. `@help` admin commands provide a guided creation/editing flow, using the existing Editor for body text only (not raw TOML). Metadata fields are prompted inline. **Tech Stack:** Python 3.12+, tomllib for reading, tomli_w for writing TOML, existing Editor class for body editing. --- ## Design ### TOML format (`content/help/.toml`) ```toml name = "zones" title = "zone building guide" admin = false body = """ zones are spatial containers - rooms, dungeons, overworld areas. each zone has a terrain grid, spawn point, and optional portals. listing zones @zones list all registered zones navigating @goto teleport to a zone's spawn point enter step through a portal to another zone building @dig create a new blank zone @paint toggle paint mode for terrain editing @save save current zone to file see: @zones, @goto, @dig, @paint, @save """ ``` Fields: - `name` (required) — topic name, matches filename stem - `title` (optional) — short description shown in listings, defaults to name - `admin` (optional) — if true, only admins can view. defaults to false - `body` (required) — the help text, multiline string ### How `help ` resolution works 1. Check `_help_topics` dict for exact match 2. If found and `admin=True`, check player.is_admin 3. If found, display title + body 4. If not found, fall through to existing command detail logic ### `@help` subcommands - `@help` — list all help topics (name + title, admin-only topics marked) - `@help create` — guided creation: prompts for name, title, admin flag, then opens editor for body - `@help edit ` — shows current metadata (enter to keep), opens editor for body - `@help remove ` — deletes topic file + removes from dict (with confirmation) ### Editor integration The editor opens with ONLY the body text. When saved, the `@help` command reassembles the full TOML and writes it. The player never sees TOML syntax. --- ## Task 1: Help topic dataclass and loader **Files:** - Modify: `src/mudlib/content.py` — add `HelpTopic` dataclass and `load_help_topics()` **Step 1: Write the failing test** Create `tests/test_help_topics.py`: ```python """Tests for TOML help topic loading.""" import textwrap from pathlib import Path import pytest from mudlib.content import HelpTopic, load_help_topics @pytest.fixture def help_dir(tmp_path): """Create a temp directory with sample help TOML files.""" topic = tmp_path / "combat.toml" topic.write_text(textwrap.dedent("""\ name = "combat" title = "combat primer" body = \"\"\" combat is initiated when you attack another entity. use skills to learn your available moves. \"\"\" """)) admin_topic = tmp_path / "building.toml" admin_topic.write_text(textwrap.dedent("""\ name = "building" title = "builder's guide" admin = true body = \"\"\" use @dig to create zones and @paint to edit terrain. \"\"\" """)) return tmp_path def test_load_help_topics(help_dir): topics = load_help_topics(help_dir) assert "combat" in topics assert "building" in topics def test_help_topic_fields(help_dir): topics = load_help_topics(help_dir) combat = topics["combat"] assert combat.name == "combat" assert combat.title == "combat primer" assert combat.admin is False assert "combat is initiated" in combat.body def test_help_topic_admin_flag(help_dir): topics = load_help_topics(help_dir) building = topics["building"] assert building.admin is True def test_help_topic_title_defaults_to_name(tmp_path): topic = tmp_path / "simple.toml" topic.write_text('name = "simple"\nbody = "just a test"\n') topics = load_help_topics(tmp_path) assert topics["simple"].title == "simple" def test_load_help_topics_empty_dir(tmp_path): topics = load_help_topics(tmp_path) assert topics == {} def test_load_help_topics_skips_bad_files(tmp_path): bad = tmp_path / "broken.toml" bad.write_text("not valid toml [[[") good = tmp_path / "good.toml" good.write_text('name = "good"\nbody = "works"\n') topics = load_help_topics(tmp_path) assert "good" in topics assert "broken" not in topics ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_help_topics.py -v` Expected: ImportError — `HelpTopic` and `load_help_topics` don't exist yet. **Step 3: Write minimal implementation** Add to `src/mudlib/content.py`: ```python @dataclass class HelpTopic: """A help topic loaded from a TOML file.""" name: str body: str title: str = "" admin: bool = False def __post_init__(self): if not self.title: self.title = self.name def load_help_topic(path: Path) -> HelpTopic: """Load a single help topic from a TOML file.""" with open(path, "rb") as f: data = tomllib.load(f) return HelpTopic( name=data["name"], body=data["body"].strip(), title=data.get("title", ""), admin=data.get("admin", False), ) def load_help_topics(directory: Path) -> dict[str, HelpTopic]: """Load all help topics from a directory of TOML files.""" topics: dict[str, HelpTopic] = {} for path in directory.glob("*.toml"): try: topic = load_help_topic(path) topics[topic.name] = topic except Exception as e: log.warning("failed to load help topic from %s: %s", path, e) return topics ``` Also add `from dataclasses import dataclass` to imports in content.py. **Step 4: Run test to verify it passes** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Add HelpTopic dataclass and TOML loader` --- ## Task 2: Wire help topics into the help command **Files:** - Modify: `src/mudlib/commands/help.py` — add topic lookup, topic listing - Modify: `src/mudlib/server.py` — load help topics at startup **Step 1: Write the failing tests** Add to `tests/test_help_topics.py`: ```python from unittest.mock import AsyncMock, MagicMock from mudlib import commands from mudlib.commands import help as help_mod # noqa: F401 from mudlib.commands.help import _help_topics @pytest.fixture def mock_writer(): writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() return writer @pytest.fixture def player(mock_writer): from mudlib.player import Player return Player(name="Tester", x=0, y=0, reader=MagicMock(), writer=mock_writer) @pytest.fixture def admin_player(mock_writer): from mudlib.player import Player p = Player(name="Admin", x=0, y=0, reader=MagicMock(), writer=mock_writer) p.is_admin = True return p @pytest.fixture(autouse=True) def _clear_topics(): _help_topics.clear() yield _help_topics.clear() @pytest.mark.asyncio async def test_help_shows_toml_topic(player): from mudlib.content import HelpTopic _help_topics["combat"] = HelpTopic( name="combat", body="fight stuff", title="combat primer" ) await commands.dispatch(player, "help combat") output = "".join(c[0][0] for c in player.writer.write.call_args_list) assert "combat primer" in output assert "fight stuff" in output @pytest.mark.asyncio async def test_help_admin_topic_hidden_from_players(player): from mudlib.content import HelpTopic _help_topics["secret"] = HelpTopic( name="secret", body="hidden", title="secret stuff", admin=True ) await commands.dispatch(player, "help secret") output = "".join(c[0][0] for c in player.writer.write.call_args_list) assert "hidden" not in output assert "unknown" in output.lower() @pytest.mark.asyncio async def test_help_admin_topic_visible_to_admins(admin_player): from mudlib.content import HelpTopic _help_topics["secret"] = HelpTopic( name="secret", body="hidden", title="secret stuff", admin=True ) await commands.dispatch(admin_player, "help secret") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "hidden" in output ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_help_topics.py -v -k "test_help_shows or test_help_admin"` Expected: ImportError on `_help_topics`. **Step 3: Write implementation** In `src/mudlib/commands/help.py`, add a module-level dict and modify `cmd_help`: ```python # At module level, after imports from mudlib.content import HelpTopic _help_topics: dict[str, HelpTopic] = {} ``` Modify `cmd_help` to check topics BEFORE the existing hidden-command check: ```python async def cmd_help(player: Player, args: str) -> None: args = args.strip() if not args: await player.send( "type help for details. see also: commands, skills, client\r\n" ) return # Check help topics first topic = _help_topics.get(args) if topic is not None: if topic.admin and not player.is_admin: await player.send(f"Unknown command: {args}\r\n") return await player.send(f"{topic.title}\r\n{topic.body}\r\n") return # Existing hidden command check... defn = _registry.get(args) if defn is not None and defn.hidden: if defn.admin and not player.is_admin: await player.send(f"Unknown command: {args}\r\n") return await defn.handler(player, "") return await _show_command_detail(player, args) ``` In `src/mudlib/server.py`, add loading after the content commands block: ```python from mudlib.content import load_help_topics from mudlib.commands.help import _help_topics # Load help topics from content/help/ help_dir = pathlib.Path(__file__).resolve().parents[2] / "content" / "help" if help_dir.exists(): loaded_topics = load_help_topics(help_dir) _help_topics.update(loaded_topics) log.info("loaded %d help topics from %s", len(loaded_topics), help_dir) ``` **Step 4: Run test to verify it passes** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Wire TOML help topics into help command` --- ## Task 3: Migrate hardcoded zones help to TOML **Files:** - Create: `content/help/zones.toml` - Modify: `src/mudlib/commands/help.py` — remove `cmd_zones_help` function and its registration - Modify: `tests/test_help_command.py` — adjust zones tests **Step 1: Create the TOML file** ```toml name = "zones" title = "zones" admin = true body = """ zones are spatial containers - rooms, dungeons, overworld areas. each zone has a terrain grid, spawn point, and optional portals. listing zones @zones list all registered zones navigating @goto teleport to a zone's spawn point enter step through a portal to another zone building @dig create a new blank zone @paint toggle paint mode for terrain editing @save save current zone to file see: @zones, @goto, @dig, @paint, @save """ ``` **Step 2: Remove `cmd_zones_help` and its registration from help.py** Delete the `cmd_zones_help` function (lines 357-378) and its `register()` call (lines 415-424). **Step 3: Update tests** The existing `test_help_zones_*` tests in `test_help_command.py` need to load the topic into `_help_topics` instead of relying on the hidden command. Update them to populate `_help_topics` with the zones topic, and remove the build import that was only needed for the old hidden command. Also update `test_help_topics.py` to add a test that verifies the zones TOML file loads correctly from `content/help/`. **Step 4: Run all tests** Run: `pytest tests/test_help_command.py tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Migrate zones help from hardcoded to TOML` --- ## Task 4: `@help` list command **Files:** - Create: `src/mudlib/commands/helpadmin.py` - Modify: `src/mudlib/server.py` — import the module **Step 1: Write the failing test** Add to `tests/test_help_topics.py`: ```python from mudlib.commands import helpadmin # noqa: F401 @pytest.mark.asyncio async def test_at_help_lists_topics(admin_player): from mudlib.content import HelpTopic _help_topics["combat"] = HelpTopic( name="combat", body="fight", title="combat primer" ) _help_topics["zones"] = HelpTopic( name="zones", body="build", title="zone guide", admin=True ) await commands.dispatch(admin_player, "@help") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "combat" in output assert "combat primer" in output assert "zones" in output assert "[admin]" in output @pytest.mark.asyncio async def test_at_help_requires_admin(player): await commands.dispatch(player, "@help") output = "".join(c[0][0] for c in player.writer.write.call_args_list) # Should be rejected by dispatch (admin=True check) assert "unknown" in output.lower() or output == "" ``` **Step 2: Run test to verify it fails** Run: `pytest tests/test_help_topics.py -v -k "at_help"` Expected: ImportError on `helpadmin`. **Step 3: Write implementation** Create `src/mudlib/commands/helpadmin.py`: ```python """Admin help topic management commands (@help).""" from mudlib.commands import CommandDefinition, register from mudlib.commands.help import _help_topics from mudlib.player import Player async def cmd_at_help(player: Player, args: str) -> None: """List all help topics or dispatch to subcommands.""" args = args.strip() if args.startswith("create"): await _cmd_create(player, args[6:].strip()) return if args.startswith("edit"): await _cmd_edit(player, args[4:].strip()) return if args.startswith("remove"): await _cmd_remove(player, args[6:].strip()) return # Default: list all topics if not _help_topics: await player.send("No help topics defined.\r\n") return lines = ["help topics"] for name in sorted(_help_topics): topic = _help_topics[name] admin_tag = " [admin]" if topic.admin else "" lines.append(f" {name:<20} {topic.title}{admin_tag}") lines.append("") lines.append(" @help create create a new topic") lines.append(" @help edit edit an existing topic") lines.append(" @help remove remove a topic") await player.send("\r\n".join(lines) + "\r\n") async def _cmd_create(player: Player, args: str) -> None: """Placeholder for guided topic creation.""" await player.send("Not yet implemented.\r\n") async def _cmd_edit(player: Player, args: str) -> None: """Placeholder for topic editing.""" await player.send("Not yet implemented.\r\n") async def _cmd_remove(player: Player, args: str) -> None: """Placeholder for topic removal.""" await player.send("Not yet implemented.\r\n") register( CommandDefinition( "@help", cmd_at_help, admin=True, mode="*", help="manage help topics" ) ) ``` Add `import mudlib.commands.helpadmin` to server.py with the other command imports. **Step 4: Run test to verify it passes** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Add @help list command for admin topic management` --- ## Task 5: `@help create` with guided prompts and editor **Files:** - Modify: `src/mudlib/commands/helpadmin.py` — implement `_cmd_create` This is the most involved task. The guided flow needs to: 1. Prompt for topic name (if not provided as arg) 2. Prompt for title 3. Prompt for admin flag (y/n) 4. Open the editor for body text 5. On editor save, assemble TOML and write to `content/help/.toml` **Step 1: Write the failing tests** Add to `tests/test_help_topics.py`: ```python @pytest.mark.asyncio async def test_at_help_create_with_all_args_opens_editor(admin_player): """@help create should set up prompting state.""" await commands.dispatch(admin_player, "@help create combat") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "title" in output.lower() @pytest.mark.asyncio async def test_at_help_create_no_args_prompts_name(admin_player): """@help create with no args prompts for the topic name.""" await commands.dispatch(admin_player, "@help create") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "name" in output.lower() ``` **Step 2: Run test to verify it fails** Expected: "Not yet implemented" in output, assertions fail. **Step 3: Write implementation** The create flow uses `player.pending_input` — a callback that intercepts the next line of input. This is the same pattern MUD systems use for multi-step prompts. We'll need to add this to the Player class if it doesn't exist, or use an equivalent mechanism. Implementation approach for prompted input: ```python async def _cmd_create(player: Player, args: str) -> None: """Guided topic creation.""" if args: # Name provided, skip to title prompt await _prompt_title(player, name=args) else: await player.send("topic name: ") player.pending_input = _on_name_input async def _on_name_input(player: Player, line: str) -> None: """Handle name input.""" name = line.strip() if not name: await player.send("Cancelled.\r\n") return if name in _help_topics: await player.send(f"Topic '{name}' already exists. Use @help edit.\r\n") return await _prompt_title(player, name=name) async def _prompt_title(player: Player, name: str) -> None: await player.send(f"title [{name}]: ") player.pending_input = lambda p, line: _on_title_input(p, line, name) async def _on_title_input(player: Player, line: str, name: str) -> None: title = line.strip() or name await player.send("admin only? [y/N]: ") player.pending_input = lambda p, line: _on_admin_input(p, line, name, title) async def _on_admin_input( player: Player, line: str, name: str, title: str ) -> None: admin = line.strip().lower() in ("y", "yes") # Open editor for body await _open_body_editor(player, name=name, title=title, admin=admin, body="") async def _open_body_editor( player: Player, name: str, title: str, admin: bool, body: str ) -> None: from mudlib.editor import Editor save_cb = _make_help_save_callback(player, name, title, admin) player.editor = Editor( save_callback=save_cb, content_type="text", color_depth=player.color_depth or "16", initial_content=body, ) player.mode_stack.append("editor") await player.send("Entering editor for body text. Type :h for help.\r\n") ``` The save callback writes the TOML file: ```python def _make_help_save_callback( player: Player, name: str, title: str, admin: bool ): async def save_callback(content: str) -> None: _write_help_toml(name, title, admin, content) # Update in-memory dict from mudlib.content import HelpTopic _help_topics[name] = HelpTopic( name=name, body=content.strip(), title=title, admin=admin ) await player.send(f"Help topic '{name}' saved.\r\n") return save_callback def _write_help_toml(name: str, title: str, admin: bool, body: str) -> None: """Write a help topic TOML file.""" from pathlib import Path help_dir = Path(__file__).resolve().parents[2] / "content" / "help" help_dir.mkdir(parents=True, exist_ok=True) path = help_dir / f"{name}.toml" lines = [f'name = "{name}"'] lines.append(f'title = "{title}"') if admin: lines.append("admin = true") lines.append(f'body = """\n{body.strip()}\n"""') path.write_text("\n".join(lines) + "\n") ``` **Important:** Check whether `player.pending_input` exists. If not, this needs to be added to the Player class and the server's input loop. Look for existing prompt patterns — if there's already a confirmation prompt somewhere, follow that pattern. If not, `pending_input` is a `Callable | None` on Player, and the server input loop checks it before dispatch: ```python # In server.py input loop, before command dispatch: if player.pending_input is not None: callback = player.pending_input player.pending_input = None await callback(player, inp) continue ``` **Step 4: Run tests** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Add @help create with guided prompts and editor` --- ## Task 6: `@help edit ` **Files:** - Modify: `src/mudlib/commands/helpadmin.py` **Step 1: Write the failing tests** ```python @pytest.mark.asyncio async def test_at_help_edit_unknown_topic(admin_player): await commands.dispatch(admin_player, "@help edit bogus") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "not found" in output.lower() @pytest.mark.asyncio async def test_at_help_edit_existing_opens_editor(admin_player): from mudlib.content import HelpTopic _help_topics["combat"] = HelpTopic( name="combat", body="old body", title="combat primer" ) await commands.dispatch(admin_player, "@help edit combat") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) # Should prompt for title with current value as default assert "combat primer" in output # Should eventually open editor assert admin_player.editor is not None assert "old body" in "\n".join(admin_player.editor.buffer) ``` **Step 2: Run test to verify it fails** Expected: "Not yet implemented" output. **Step 3: Write implementation** ```python async def _cmd_edit(player: Player, args: str) -> None: """Edit an existing help topic.""" name = args.strip() if not name: await player.send("Usage: @help edit \r\n") return topic = _help_topics.get(name) if topic is None: await player.send(f"Topic '{name}' not found.\r\n") return await player.send(f"title [{topic.title}]: ") player.pending_input = lambda p, line: _on_edit_title( p, line, name, topic ) async def _on_edit_title(player, line, name, topic): title = line.strip() or topic.title await player.send(f"admin only? [{'Y' if topic.admin else 'y'}/{'N' if not topic.admin else 'n'}]: ") player.pending_input = lambda p, line: _on_edit_admin( p, line, name, title, topic ) async def _on_edit_admin(player, line, name, title, topic): if line.strip(): admin = line.strip().lower() in ("y", "yes") else: admin = topic.admin await _open_body_editor(player, name=name, title=title, admin=admin, body=topic.body) ``` **Step 4: Run tests** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Add @help edit for modifying existing topics` --- ## Task 7: `@help remove ` **Files:** - Modify: `src/mudlib/commands/helpadmin.py` **Step 1: Write the failing tests** ```python @pytest.mark.asyncio async def test_at_help_remove_unknown_topic(admin_player): await commands.dispatch(admin_player, "@help remove bogus") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "not found" in output.lower() @pytest.mark.asyncio async def test_at_help_remove_prompts_confirmation(admin_player): from mudlib.content import HelpTopic _help_topics["combat"] = HelpTopic( name="combat", body="fight", title="combat primer" ) await commands.dispatch(admin_player, "@help remove combat") output = "".join(c[0][0] for c in admin_player.writer.write.call_args_list) assert "confirm" in output.lower() or "y/n" in output.lower() ``` **Step 2: Run test to verify it fails** Expected: "Not yet implemented" output. **Step 3: Write implementation** ```python async def _cmd_remove(player: Player, args: str) -> None: """Remove a help topic.""" name = args.strip() if not name: await player.send("Usage: @help remove \r\n") return if name not in _help_topics: await player.send(f"Topic '{name}' not found.\r\n") return await player.send(f"Remove topic '{name}'? [y/N]: ") player.pending_input = lambda p, line: _on_remove_confirm(p, line, name) async def _on_remove_confirm(player: Player, line: str, name: str) -> None: if line.strip().lower() not in ("y", "yes"): await player.send("Cancelled.\r\n") return # Remove from memory _help_topics.pop(name, None) # Remove file from pathlib import Path path = Path(__file__).resolve().parents[2] / "content" / "help" / f"{name}.toml" if path.exists(): path.unlink() await player.send(f"Topic '{name}' removed.\r\n") ``` **Step 4: Run tests** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Add @help remove with confirmation prompt` --- ## Task 8: Add `pending_input` to Player and server loop This task may need to be done BEFORE task 5 if `pending_input` doesn't exist on Player yet. Check first — if there's already a prompt/callback mechanism, use that instead. **Files:** - Modify: `src/mudlib/player.py` — add `pending_input` field - Modify: `src/mudlib/server.py` — check `pending_input` before dispatch **Step 1: Write the failing test** ```python def test_player_has_pending_input(): from mudlib.player import Player from unittest.mock import MagicMock p = Player(name="Test", x=0, y=0, reader=MagicMock(), writer=MagicMock()) assert p.pending_input is None ``` **Step 2: Run test to verify it fails** Expected: AttributeError. **Step 3: Write implementation** In Player dataclass, add: ```python pending_input: Callable | None = None ``` In server.py input loop (find where `dispatch` is called), add before it: ```python if player.pending_input is not None: callback = player.pending_input player.pending_input = None await callback(player, inp) continue ``` **Step 4: Run tests** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 5: Commit** Message: `Add pending_input callback to Player for prompted input` --- ## Task 9: Create `help @help` topic **Files:** - Create: `content/help/@help.toml` **Step 1: Create the file** ```toml name = "@help" title = "help system administration" admin = true body = """ the help system stores topics as TOML files in content/help/. each topic has a name, title, optional admin flag, and body text. commands @help list all help topics @help create create a new topic (guided) @help create create a new topic with the given name @help edit edit an existing topic @help remove remove a topic creating topics @help create walks you through each field: name the topic name (what players type after 'help') title short description shown in listings admin only whether only admins can view it body the help text (opens in the editor) editing topics @help edit shows current values for each field. press enter to keep the current value. the editor opens with the existing body text. files topics are stored in content/help/.toml they can also be edited directly with any text editor. changes are loaded at server startup. """ ``` **Step 2: Write test** ```python def test_at_help_toml_loads_from_content(): from pathlib import Path from mudlib.content import load_help_topics help_dir = Path(__file__).resolve().parents[1] / "content" / "help" if help_dir.exists(): topics = load_help_topics(help_dir) assert "@help" in topics assert topics["@help"].admin is True ``` **Step 3: Run tests** Run: `pytest tests/test_help_topics.py -v` Expected: all PASS. **Step 4: Commit** Message: `Add help @help topic for admin documentation` --- ## Task execution order Tasks 1-3 are sequential (each builds on the last). Task 8 (pending_input) must be done before Task 5. Task 4 can happen after Task 2. Tasks 5-7 are sequential. Task 9 can happen any time after Task 2. Suggested order: **1 → 2 → 3 → 8 → 4 → 5 → 6 → 7 → 9** Parallelizable after Task 2: Tasks 3 and 8 can be done in parallel. After Task 8: Tasks 4 and 5 can start (4 is simpler, do first).