module DirectchatPrd
DirectchatPrd companion bot โ PRD section 13 (fandom persona offline sync). Public: companion_bot_lines Depends: directchat_prd constants Tests: test/lib/directchat_prd_test.rb
DirectchatPrd core โ PRD sections 1-11 and catalog helper methods. Public: core_lines, dialog_note, reference_hint, popup_label Depends: directchat_prd constants Tests: test/lib/directchat_prd_test.rb
DirectchatPrd foundations โ PRD section 14 (foundations supplement pointer). Public: foundations_lines Depends: directchat_prd constants Tests: test/lib/directchat_prd_test.rb
DirectchatPrd legacy caveats โ PRD section 12 (outdated services assumptions). Public: legacy_reference_caveats_lines Depends: directchat_prd constants Tests: test/lib/directchat_prd_test.rb
Constants
- DIALOG_NOTES
- FEATURE_MODULES
- FOUNDATIONS_FILENAME
- INVENTORY_FILENAME
- OUTPUT_FILENAME
- STYLE_TITLE
- VERSION
Public Class Methods
Source
# File lib/directchat/prd.rb, line 66 def self.build(catalog:) lines = [] lines.concat(core_lines(catalog: catalog)) lines.concat(legacy_reference_caveats_lines) lines.concat(companion_bot_lines) lines.concat(foundations_lines) lines << "**End of PRD.** Generated by `!directchat prd` from Rabbot directchat plugin." lines << "Foundations supplement: docs/#{FOUNDATIONS_FILENAME} (ยง14โยง24)." lines << "Script reference: docs/#{INVENTORY_FILENAME}." lines << "" lines.join("\n") end
Source
# File lib/directchat_prd/companion_bot.rb, line 9 def self.companion_bot_lines [ "## 13. Fandom companion bot (offline sync)", "", "Closing the browser tab drops the ActionCable subscription. Without a persistent agent, missed messages, nick-list changes, and topics are lost when the user returns.", "", "Each web IRC user may enable a **companion bot** in settings. While enabled, a Rabbot instance (persona from a fandom.com wiki page) maintains channel presence and writes structured events to server storage. On reconnect, the client fetches and renders everything since its last cursor โ no manual scroll-back or `/names` refresh.", "", "### 13.1 Problem and solution", "", "**Problem:** The web client is ephemeral; IRC traffic continues whether or not a tab is open.", "", "**Solution:** An opt-in companion bot acts as a per-user bouncer (see ยง3 shared bouncer model). It logs IRC events server-side and replays them when the browser reconnects.", "", "### 13.2 Settings (options dialog)", "", "Add to the **settings** dialog (ยง5 dialogs / Phase 2 deliverables):", "", "| Setting | Type | Notes |", "|---------|------|-------|", "| `companion_bot_enabled` | boolean | Off by default; master toggle |", "| `companion_fandom_url` | URL | Any `*.fandom.com/wiki/...` page (character, episode, item, location, etc.) |", "| `companion_visibility` | enum | `:visible` (separate nick in channel) or `:invisible` (logger-only; no channel nick) |", "| `companion_stay_in_channels` | boolean | When tab closed, bot remains in channels user had open (default: true) |", "", "**Activation flow:**", "", "1. User pastes fandom URL โ server validates via `CharacterTranscripts.fandom_url?`", "2. Server generates or loads `CharacterProfile` (reuse `CharacterProfileGenerator` for first-time URLs; cache YAML per user)", "3. Preview shows character name + summary before save", "4. On enable, provision companion bot config (nick derived from profile `name`, IRC-sanitized)", "5. On disable, graceful quit; optional event retention policy (keep N days for re-enable)", "", "### 13.3 Fandom persona source", "", "Any fandom wiki page is valid โ not limited to characters:", "", "- Character pages (primary example; matches existing Dr. Weird flow)", "- Episode / transcript pages", "- Locations, items, factions โ generator extracts title + summary + traits from MediaWiki API (`CharacterProfileGenerator.fetch_api_source`)", "", "Reuse existing Rabbot libs (`lib/character_profile.rb`, `lib/character_profile_generator.rb`, `lib/character_transcripts.rb`); no new fandom parser required for v1.", "", "**Bot behaviour (non-sync):** Companion is a lightweight Rabbot instance โ `CharacterResponder` + minimal plugins only (no AI spam). Persona is flavour; sync is the primary job.", "", "### 13.4 Sync and catch-up", "", "**Event store** (new service; do not reuse `AiChannelLog`, which caps at 10 entries):", "", "- `IrcEventStore` โ append-only, keyed by `(user_id, network, channel, monotonic_id)`", "- Event types: `PRIVMSG`, `NOTICE`, `JOIN`, `PART`, `QUIT`, `TOPIC`, `NICK`, `MODE`, `KICK`", "- Store plain + formatted text (formatted via `event_formatter.rb` / `IrcFormat`)", "", "**Cursor model:**", "", "- Browser persists `last_event_id` per `(network, channel)` in session + localStorage", "- On ActionCable subscribe / Turbo reconnect: `GET /irc/sync?since=<cursor>` โ Turbo Stream batch append", "- Nick list and topic bar updated from latest snapshot events in the batch", "", "### 13.5 Visibility modes", "", "- **Visible mode:** Bot joins when user joins; mirrors channel set; separate nick visible to others", "- **Invisible mode:** Bot does not JOIN channels; piggybacks on the user's server-side IRC connection (bouncer worker) and logs from that read loop โ preferred when channel etiquette matters", "", "User selects mode via `companion_visibility` in settings (see ยง13.2).", "", "### 13.6 Architecture", "", "See ยง4 architecture for `irc_companion_profile.rb`, `irc_event.rb`, `companion_bot_provisioner.rb`, `event_store.rb`, `sync_catchup.rb`, and `companion_bot_lifecycle_job.rb`.", "", "**Real-time catch-up flow:**", "", "1. Companion bot (or bouncer worker) appends IRC events to `IrcEventStore`", "2. Browser tracks `last_event_id` per channel", "3. On reconnect, `sync_catchup.rb` builds Turbo Stream payload for events since cursor", "4. Stimulus appends missed lines and refreshes nick list / topic from snapshot events", "", "### 13.7 Phased deliverables", "", "Phase 2 (core companion bot):", "", "- [ ] Settings: companion bot toggle, fandom URL field, visibility option", "- [ ] Fandom URL validation + profile preview/generation", "- [ ] `IrcEventStore` with cursor-based replay API", "- [ ] ActionCable / Turbo catch-up on reconnect", "", "Phase 3 (hardening):", "", "- [ ] Visible-mode channel mirroring (bot JOIN/PART sync with user)", "- [ ] Retention policy + storage compaction", "- [ ] Companion status in status bar", "", "### 13.8 Success metrics", "", "See ยง11 success metrics for companion-bot-specific targets.", "", "### 13.9 Non-goals", "", "- Companion bot is not a full AI chat agent in channels (persona flavour only)", "- No automatic fandom page picking โ user supplies URL", "- No cross-user shared companions", "", "---", "" ] end
Source
# File lib/directchat_prd/core.rb, line 9 def self.core_lines(catalog:) features = Array(catalog[:features]).map(&:to_sym) lines = [] lines << "# PRD: Hotwire IRC Client (from Ibis / mIRC reference scripts)" lines << "" lines << "**Version:** #{VERSION}" lines << "**Status:** Ready for implementation" lines << "**Source analysis:** #{catalog[:script_dirs].join(', ')} ยท #{catalog[:files].length} script files" lines << "**Output path:** docs/#{OUTPUT_FILENAME}" lines << "**Stack:** Ruby on Rails 8, Hotwire (Turbo + Stimulus), ActionCable, SQLite/PostgreSQL" lines << "" lines << "---" lines << "" lines << "## 1. Executive summary" lines << "" lines << "Build a **fully featured web IRC client** that recreates the ergonomics of the legacy **Ibis mIRC script suite** (`reference/stuff`, formerly `reference/newmirc`) using modern Rails tooling." lines << "" lines << "Real-time chat flows through **ActionCable** WebSockets; UI updates use **Turbo Streams** and **Stimulus** controllers for nick lists, topic bars, and context menus. The goal is desktop-class IRC in the browser without requiring mIRC." lines << "" lines << "Analysis scanned **#{catalog[:event_handlers]}** event handlers, **#{catalog[:popup_items]}** popup menu items, and **#{catalog[:dialogs].length}** dialog definitions from the reference scripts." if catalog[:mirc_pdf] lines << "" lines << "The bundled **mirc.pdf** scripting reference is available under `reference/mirc.pdf` for alias/syntax parity during implementation." end lines << "" lines << "### Phased delivery" lines << "" lines << "| Phase | Goal | Outcome |" lines << "|-------|------|---------|" lines << "| **Phase 1** | Core client | Connect, join/part channels, styled message log, nick list, PMs |" lines << "| **Phase 2** | Power user | Context menus, CTCP, modes, notify/ignore, logging, dialogs |" lines << "| **Phase 3** | Advanced | DCC/XDCC bridge, services auth, auto-voice/op, flood limits |" lines << "" lines << "---" lines << "" lines << "## 2. Problem statement" lines << "" lines << "The Ibis script collection (`style.ibis`, `popups_*.ibis`, `dialogs.ibis`, `events.ibis`, etc.) implements a rich IRC workflow:" lines << "" lines << "- Coloured, indented channel logs with custom join/part/kick/topic formatting" lines << "- Deep right-click menus on nicks, channels, and the status window" lines << "- DCC chat/send, CTCP tooling, and XDCC leech helpers" lines << "- Settings dialogs for flood protection, auto-join, auto-connect, and away behaviour" lines << "" lines << "No browser client in the Rabbot ecosystem reproduces this surface area. A Rails Hotwire app provides a maintainable path to parity while keeping server-side IRC connection logic testable." lines << "" lines << "---" lines << "" lines << "## 3. Goals and non-goals" lines << "" lines << "### Goals" lines << "" lines << "- **ActionCable** bridge between IRC server and browser sessions (one server-side IRC connection per user or shared bouncer model)" lines << "- **Hotwire** UI: Turbo Frames for channel tabs, Turbo Streams for incremental log lines" lines << "- **Stimulus** controllers for context menus, mode toggles, and dialog panels" lines << "- Feature parity with detected reference capabilities (see section 5)" lines << "- mIRC colour code rendering (0โ98) matching Rabbot `IrcFormat` conventions" lines << "- Responsive layout: channel list, chat pane, nick list, status bar" lines << "- **Companion bot:** Opt-in per-user Rabbot with fandom.com persona; persists IRC events while browser tab is closed; replays on reconnect" lines << "" lines << "### Non-goals (v1)" lines << "" lines << "- Binary-compatible mIRC script execution in the browser" lines << "- Full Windows-only integrations (Notepad, Explorer paths from popups)" lines << "- IRCnet/EFnet-specific exploits or channel flooding tools from legacy scripts" lines << "" lines << "---" lines << "" lines << "## 4. Architecture overview" lines << "" lines << "```" lines << "app/" lines << " channels/irc_channel.rb # ActionCable โ stream log lines to browser" lines << " controllers/irc/" lines << " sessions_controller.rb # Connect/disconnect IRC" lines << " channels_controller.rb # Join/part, topic, modes" lines << " messages_controller.rb # Send channel/PM text" lines << " context_menus_controller.rb # Popup menu actions (Turbo responses)" lines << " javascript/controllers/" lines << " irc_log_controller.js # Append Turbo Stream fragments" lines << " context_menu_controller.js # popups_* parity" lines << " dialog_controller.js # settings/flood/lock dialogs" lines << " models/" lines << " irc_connection.rb # Server-side IRC client (Cinch / ircinch)" lines << " irc_message.rb # Parsed PRIVMSG/NOTICE/JOIN/PART" lines << " irc_companion_profile.rb # user โ fandom URL โ character YAML path" lines << " irc_event.rb # persisted IRC events for replay" lines << " services/irc/" lines << " event_formatter.rb # style.ibis coloured output" lines << " ctcp_handler.rb # ping/time/version" lines << " dcc_coordinator.rb # Phase 3 โ WebRTC or relay fallback" lines << " network_profile.rb # per-network services + startup config" lines << " capability_probe.rb # parse 004/005/MOTD into supported features" lines << " connection_notes.rb # @stuff parity โ structured connect log" lines << " companion_bot_provisioner.rb # spawn/stop Rabbot from profile" lines << " event_store.rb # append + cursor query" lines << " sync_catchup.rb # build Turbo Stream replay payload" lines << " jobs/" lines << " companion_bot_lifecycle_job.rb # enable/disable, channel mirror" lines << "lib/irc_format.rb # Shared with Rabbot โ mIRC colours" lines << "lib/character_profile.rb # existing โ persona source" lines << "lib/character_profile_generator.rb # existing โ URL โ YAML" lines << "```" lines << "" lines << "### Real-time flow" lines << "" lines << "1. Browser subscribes to `IrcChannel` with session token" lines << "2. Rails IRC worker receives `PRIVMSG` โ parses โ `IrcMessage` record (optional) โ broadcasts Turbo Stream" lines << "3. Stimulus updates scroll position, nick highlights, and unread badges" lines << "" lines << "---" lines << "" lines << "## 5. Feature mapping (reference โ web)" lines << "" lines << "Detected from `#{catalog[:references].join('`, `')}`:" lines << "" lines << "| Feature | Reference | Web implementation |" lines << "|---------|-----------|-------------------|" features.each do |feature| desc = FEATURE_MODULES[feature] || feature.to_s.tr("_", " ") ref = reference_hint(feature, catalog) lines << "| #{feature} | #{ref} | #{desc} |" end lines << "| companion_bot | settings.ibis (new) | #{FEATURE_MODULES[:companion_bot]} |" lines << "" lines << "Detailed script inventory, alias notes, and popup mapping: [docs/#{INVENTORY_FILENAME}](ibis-script-reference.md)." lines << "" lines << "### Context menus (popups_*.ibis)" lines << "" lines << "Implement Stimulus-driven menus mirroring:" lines << "" Array(catalog[:popups]).sort.each do |popup| lines << "- `#{popup}` โ #{popup_label(popup)}" end lines << "" lines << "### Dialogs" lines << "" lines << "Priority matrix: see [docs/#{FOUNDATIONS_FILENAME}](irc-client-foundations.md) ยง20." lines << "" if catalog[:dialogs].empty? lines << "_No dialog blocks detected in scan; include settings/flood/lock from manual review._" else catalog[:dialogs].each do |dialog| lines << "- `#{dialog}` โ #{dialog_note(dialog)}" end end lines << "" lines << "---" lines << "" lines << "## 6. UI / UX requirements" lines << "" lines << "- **Message log:** Plain body text; colour only on nicks, joins/parts, topics, and timestamps (match `lib/CONVENTIONS.md` ยง IRC presentation)" lines << "- **Nick list:** Group by op/voice; colour from `style.ibis` nick colouring rules" lines << "- **Status bar:** Network, lag, uptime, unread PM count (from `popups_status.ibis` / titlebar toggles)" lines << "- **Channel tab bar:** Auto-join list persistence (localStorage + server profile)" lines << "- **Mobile:** Collapsible nick list; long-press for context menu" lines << "" lines << "---" lines << "" lines << "## 7. Technical requirements" lines << "" lines << "- Rails 8, import maps, Turbo 8, Stimulus 3" lines << "- `solid_cable` or Redis for ActionCable adapter in production" lines << "- Background job for IRC read loop (`IrcReadJob`) with graceful reconnect" lines << "- Encrypted credentials per `IrcNetworkProfile`; never log raw `PASS`/`IDENTIFY`" lines << "- Never emit services auth commands unless the network profile and connection notes (MOTD + startup numerics) confirm support" lines << "- Rate limiting mirroring flood protection dialog (`allow N CTCP events every M seconds`)" lines << "- System tests with Capybara + ActionCable test helper" lines << "" lines << "---" lines << "" lines << "## 8. Phase 1 deliverables (MVP)" lines << "" lines << "- [ ] IRC connect form (server, port, nick, SSL toggle)" lines << "- [ ] Join/part channel; live message stream via ActionCable" lines << "- [ ] Styled JOIN/PART/QUIT/TOPIC lines from `style.ibis` rules" lines << "- [ ] Private message pane" lines << "- [ ] Basic nick list with op/voice prefixes" lines << "" lines << "## 9. Phase 2 deliverables" lines << "" lines << "- [ ] Context menus (`popups_chat`, `popups_channel`, `popups_nicklist`)" lines << "- [ ] CTCP ping/time/version" lines << "- [ ] Channel mode toggles from `popups_channel.ibis`" lines << "- [ ] Notify and ignore lists" lines << "- [ ] Settings dialog (auto-join, colours, echo indent)" lines << "- [ ] Channel/query logging (downloadable)" lines << "- [ ] Settings: companion bot toggle, fandom URL field, visibility option" lines << "- [ ] Fandom URL validation + profile preview/generation" lines << "- [ ] `IrcEventStore` with cursor-based replay API" lines << "- [ ] ActionCable / Turbo catch-up on reconnect" lines << "" lines << "## 10. Phase 3 deliverables" lines << "" lines << "- [ ] DCC chat relay (WebSocket tunnel or external helper)" lines << "- [ ] File upload/send with progress bar" lines << "- [ ] XDCC pack browser (read-only; no leech automation)" lines << "- [ ] Per-network services auth (profile + MOTD/numeric discovery)" lines << "- [ ] Auto-voice/op rules engine" lines << "- [ ] Flood protection enforcement" lines << "- [ ] Visible-mode channel mirroring (bot JOIN/PART sync with user)" lines << "- [ ] Retention policy + storage compaction" lines << "- [ ] Companion status in status bar (e.g. \"Dr. Weird is watching #trout\")" lines << "" lines << "---" lines << "" lines << "## 11. Success metrics" lines << "" lines << "- Connect and chat on Undernet test channel within 30 seconds of signup" lines << "- Sub-200ms Turbo Stream delivery for 95th percentile local messages" lines << "- Context menu covers โฅ80% of `popups_chat.ibis` actions (excluding OS-specific ones)" lines << "- mIRC colour rendering matches Rabbot golden fixtures" lines << "- Services commands (identify, ghost, ChanServ recovery) only fire when network profile and connection notes confirm capability" lines << "- Reopen tab after 5+ minutes away โ 100% of missed messages appear within 2s without manual refresh" lines << "- Fandom URL โ usable companion nick + profile in <30s (cached profile <2s)" lines << "- Disabling companion bot quits IRC within 10s; no ghost nicks after 60s" lines << "" lines << "---" lines << "" lines end
Source
# File lib/directchat_prd/core.rb, line 231 def self.dialog_note(dialog) note = DIALOG_NOTES[dialog.to_s] return "Turbo Frame modal โ #{note}" if note && !note.start_with?("drop") return "**drop v1** โ not in scope" if note&.start_with?("drop") "Turbo Frame modal with form submit" end
Source
# File lib/directchat/prd.rb, line 79 def self.format_reply(catalog:, path:) rel = path.to_s.sub(%r{\A.*/docs/}, "docs/") meta = [ "#{catalog[:files].length} scripts", "#{catalog[:features].length} features", "#{catalog[:popup_items]} menu items" ].join(" ยท ") [ IrcFormat.report_header(tag: "DCHAT", title: "IRC client PRD written", style: STYLE_TITLE), "Saved Hotwire + ActionCable PRD to #{rel}.", "Mapped #{catalog[:features].length} mIRC capabilities for Rails implementation.", IrcFormat.report_footer(meta) ].join("\n") end
Source
# File lib/directchat_prd/foundations.rb, line 9 def self.foundations_lines [ "## 14. Foundations supplement", "", "Build-from-scratch decisions (gem packaging, optional auth, schema, workers, embed API,", "style.ibis mapping, dialog triage, deployment, tests) are documented in", "[docs/#{FOUNDATIONS_FILENAME}](irc-client-foundations.md).", "", "Key decisions:", "", "- **`rabbot-irc` gem** โ Rails engine embeddable in any host app; mount full-page or attach to any DOM element via Stimulus `irc-chat`", "- **Optional accounts** โ anonymous IRC connect; accounts unlock persistence, saved networks, and companion bot (ยง13)", "- **Cinch workers** โ long-lived `bin/irc_worker` holds sockets; Solid Queue for short jobs only", "- **Scale path** โ Postgres + Redis + Vinyl HTTP edge; see foundations ยง23", "- **Federated donate** โ optional volunteer worker mesh; see foundations ยง24", "", "---", "" ] end
Source
# File lib/directchat_prd/legacy_caveats.rb, line 9 def self.legacy_reference_caveats_lines [ "## 12. Legacy reference caveats", "", "The Ibis mIRC scripts in `reference/stuff` were written for **2000s-era DALnet/Undernet** networks. NickServ, ChanServ, and MemoServ menu items, startup aliases, and raw numeric handlers are **historical defaults** โ not a portable spec for a modern web client.", "", "### 12.1 Outdated services assumptions", "", "Do **not** port these patterns literally:", "", "- `nickserv IDENTIFY %IbisWord` โ global identify on connect/away-return (`aliases1.ibis`, `input.ibis`, `popups_menubar.ibis`)", "- `nickserv ghost` / `RECOVER` โ auto-ghost on nick-in-use (`raw 433` in `raw.ibis`)", "- `chanserv set $chan mlock -i` / `chanserv unban` โ join-failure recovery (`raw 473`/`474` in `raw.ibis`)", "- Register prompts baked into `raw 477`", "", "Modern networks differ: SASL PLAIN, CertFP, different service nicks (e.g. `nickserv@services.dal.net` on DALnet via `pns` in `aliases1.ibis`), or no services at all (EFnet). Auth and recovery commands must be configured **per network**, never assumed globally.", "", "### 12.2 Per-network connection profiles", "", "Mirror mIRC's `servers.ini` groups with a saved `IrcNetworkProfile` per network:", "", "| Field | Purpose |", "|-------|---------|", "| `services_available` | Whether services exist on this network |", "| `nickserv_target` | Nick or `nick@host` (e.g. legacy `nickserv@services.dal.net`) |", "| `auth_strategy` | `:none`, `:msg_identify`, `:sasl_plain`, etc. |", "| `supported_commands` | Subset of identify / ghost / recover / register actually used |", "| `chanserv_join_recovery` | Whether to attempt invite/ban recovery on 473/474 |", "| `startup_sequence` | Ordered hooks (ghost โ nick โ identify โ mode +i โ autojoin delay) |", "", "Encrypted credentials stay **per profile**, not one global `%ibisword`.", "", "### 12.3 Connection notes โ capability discovery", "", "The legacy **@stuff** status window (`events.ibis` opens it on start; `raw.ibis` feeds it) is the model for the web client's connection log. On each connect, parse and surface startup IRC numerics, then gate services commands on what the server actually supports.", "", "**Startup IRC numerics** (from `reference/stuff/raw.ibis`):", "", "| Numeric | Legacy use | Web client use |", "|---------|------------|----------------|", "| 001โ003 | Connection banner | Status bar + connection notes |", "| 004โ005 | `%stUserModes`, `%stChanModes`, PREFIX, CHANTYPES | Mode UI + dynamic menus (`setDynamicMenu`) |", "| 251โ255, 265โ266 | `echo_startup_stats` / startup dialog | Optional stats panel (parity `blah.ibis` startup dialog) |", "| 375โ377 | `@MOTD` window + MOTD footer | MOTD pane; scan for register/identify instructions |", "| 378 | \"connecting\" server messages | Connection progress log |", "| 433 | Auto-ghost | **Only if** profile enables ghost |", "| 473/474/477 | ChanServ recovery prompts | **Only if** profile enables chanserv recovery |", "", "MOTD text often documents the network's real registration flow โ treat it as authoritative over hardcoded menu commands. Read connection notes (MOTD + startup numerics) to determine what is supported before enabling NickServ/ChanServ actions.", "", "### 12.4 Implementation mapping", "", "See ยง4 architecture: `network_profile.rb`, `capability_probe.rb`, and `connection_notes.rb` under `app/services/irc/`.", "", "---", "" ] end
Source
# File lib/directchat/prd.rb, line 94 def self.output_path(project_root:) File.join(File.expand_path(project_root.to_s), "docs", OUTPUT_FILENAME) end
Source
# File lib/directchat_prd/core.rb, line 249 def self.popup_label(filename) case filename when "popups_chat.ibis" then "query/private chat window" when "popups_channel.ibis" then "channel window" when "popups_nicklist.ibis" then "nick list selection" when "popups_status.ibis" then "status window" when "popups_menubar.ibis" then "main menu bar" else "context menu" end end
Source
# File lib/directchat_prd/core.rb, line 239 def self.reference_hint(feature, catalog) case feature when :style_reference then "style.ibis" when :context_menus then catalog[:popups].join(", ") when :dialogs then catalog[:dialogs].join(", ") else catalog[:references].find { |ref| ref.include?(feature.to_s.tr("_", "")) } || "events/aliases" end end
Source
# File lib/directchat/prd.rb, line 98 def self.write(project_root:, catalog: nil) root = File.expand_path(project_root.to_s) catalog ||= DirectchatMircCatalog.analyze(root: root) path = output_path(project_root: root) FileUtils.mkdir_p(File.dirname(path)) File.write(path, build(catalog: catalog)) { path: path, catalog: catalog } end