From a17befa113578a2f6d7506191a8372d5a6e40fee Mon Sep 17 00:00:00 2001 From: Ally Founder Date: Tue, 11 Aug 2026 17:32:54 -0400 Subject: [PATCH] docs: RosePlugs plan - insert new section at top, confirmed live --- .../2026-08-11-roseplugs-implementation.md | 964 ++++++++++++++++++ 1 file changed, 964 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-roseplugs-implementation.md diff --git a/docs/superpowers/plans/2026-08-11-roseplugs-implementation.md b/docs/superpowers/plans/2026-08-11-roseplugs-implementation.md new file mode 100644 index 0000000..a531c37 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-roseplugs-implementation.md @@ -0,0 +1,964 @@ +# RosePlugs Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build RosePlugs, a new Revenge/Vendetta plugin that adds a top-level "Rosie's Plugs" settings section containing a quick-install browser for Rosie's plugins plus a direct settings shortcut for each of Rosie's plugins that's currently installed. + +**Architecture:** A single self-contained plugin in `/root/revenge-plugins/plugins/rose-plugs/`. Two patch mechanisms adapted from Raiden's Themes' already-shipping `SETTING_RENDERER_CONFIG`/`createList` pattern (inserting a whole new section object instead of appending a row to an existing one), plus a discovery function that reads `@vendetta/plugins`' `plugins` record to find Rosie-authored installed plugins, plus a quick-install browser UI backed by the live Nexus JSON API. + +**Tech Stack:** TypeScript, React (via `@vendetta/metro/common`), React Native components, Vendetta/Revenge plugin APIs (`@vendetta/plugins`, `@vendetta/metro`, `@vendetta/patcher`, `@vendetta/ui/*`), this repo's Rollup+SWC build (`build.mjs`), GitHub Actions + Cloudflare Workers deploy, revenge-devtools MCP for live verification. + +## Global Constraints + +- Plugin directory: `plugins/rose-plugs/` (kebab-case, matches every other plugin in this repo). No registration step needed — `build.mjs` auto-discovers every directory under `plugins/`. +- Manifest: id `rose-plugs`, name `RosePlugs`, author `Rosie`, `main: "src/index.ts"`. +- Section label/title inserted into Settings: label `ROSES_PLUGS`, title `Rosie's Plugs` (exact string). +- `@vendetta/plugins`' `plugins` record is keyed by the plugin's full install-source URL (e.g. `https://rp.jarviscli.dev/server-drawer/install/`), **not** a short id. Every lookup against it must use the full URL. +- `getSettings(id)` returns a component **function**, confirmed live — despite `vendetta-types`' `.d.ts` claiming it returns `JSX.Element`. Always check `typeof result === "function"` before using it, and wrap with `React.createElement(Component)` when rendering it — never call it directly or treat the return value as an already-built element. +- Discovery filter is `manifest.authors` containing an author named exactly `"Rosie"` (confirmed via live test: catches 17 of this repo's plugins; deliberately excludes Raiden's Themes, whose manifest credits "Raiden", and RP Admin, whose manifest credits "everestmcarthur" — both exclusions are intentional per user decision, not bugs to fix). +- Nexus plugin metadata: `GET https://rp.jarviscli.dev/plugins-data.json`, an array of objects shaped exactly like `NexusPlugin` in Task 5. +- Every task's deploy/verify steps use this repo's standard flow: `npm run build` locally first (catches TS/bundle errors before any push) → commit → `git push origin main` → `gh run watch --exit-status` → purge Cloudflare cache for zone `76e85aab7728490e7f6351ac7b8b176f` via the `cloudflare-api` MCP tool (`POST /zones/76e85aab7728490e7f6351ac7b8b176f/purge_cache` with `{"purge_everything": true}`) → live-verify via the `revenge-devtools` MCP (`devtools_clients` to confirm a client is connected, `eval`/`get_logs` to confirm actual behavior). No task is done on a "should work" basis — confirm it live before checking the box. +- Reopening a live client's Settings screen (navigate out, then back in) is enough to get a fresh `createList` call — confirmed directly in this session by capturing a live call. A full app reload is not required to see settings changes. +- The new section is inserted at the very top of Settings (`sections.unshift(...)`, not anchored next to any existing section) — confirmed live via a scratch `eval` patch before Task 2 was written; the user explicitly asked for top placement after seeing an end-anchored version first. +- Code comments only where the *why* is genuinely non-obvious (a confirmed live quirk, a workaround). Never comment what the code visibly does. + +--- + +### Task 1: Scaffold the plugin + +**Files:** +- Create: `plugins/rose-plugs/manifest.json` +- Create: `plugins/rose-plugs/src/index.ts` +- Create: `plugins/rose-plugs/src/ui/Settings.tsx` + +**Interfaces:** +- Produces: a buildable, installable plugin with a no-op `onLoad`/`onUnload` and a real `settings` export, reachable the normal way through Revenge's own Plugins list. Nothing here yet inserts the new section — that's Task 2. + +- [ ] **Step 1: Create the manifest** + +`plugins/rose-plugs/manifest.json`: +```json +{ + "name": "RosePlugs", + "description": "Gives Rosie's plugins their own home in Settings, with one-tap installs for anything not installed yet.", + "authors": [ + { "name": "Rosie" } + ], + "main": "src/index.ts", + "vendetta": { + "icon": "ic_group" + } +} +``` + +- [ ] **Step 2: Create the placeholder settings screen** + +`plugins/rose-plugs/src/ui/Settings.tsx`: +```tsx +import { React } from "@vendetta/metro/common"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import NoteBox from "@shared/ui/NoteBox"; + +export default function Settings() { + return ( + + + RosePlugs gathers Rosie's other plugins under their own "Rosie's Plugs" section in + Settings, with a quick-install browser for anything not installed yet. + + + ); +} +``` + +- [ ] **Step 3: Create the plugin entry point** + +`plugins/rose-plugs/src/index.ts`: +```ts +import Settings from "./ui/Settings"; + +export default { + onLoad: () => {}, + onUnload: () => {}, + settings: Settings, +}; +``` + +- [ ] **Step 4: Build locally** + +Run: `cd /root/revenge-plugins && npm run build` +Expected: `Successfully built RosePlugs!` appears in the output alongside every other plugin, no errors. + +- [ ] **Step 5: Commit** + +```bash +git add plugins/rose-plugs +git commit -m "RosePlugs: scaffold plugin" +``` + +- [ ] **Step 6: Push and deploy** + +```bash +git push origin main +gh run list --limit 1 +``` +Then watch the run: `gh run watch --exit-status` (use the run id from the previous command). + +- [ ] **Step 7: Purge the Cloudflare cache** + +Use the `cloudflare-api` MCP tool to `POST /zones/76e85aab7728490e7f6351ac7b8b176f/purge_cache` with `{"purge_everything": true}`. + +- [ ] **Step 8: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm a client is connected. +2. Install RosePlugs on the connected device via Revenge's Settings → Plugins → Add Plugin, using the install URL `https://rp.jarviscli.dev/rose-plugs/install/`. +3. Confirm it appears in Revenge's own Plugins list, doesn't crash on load, and tapping into its settings shows the NoteBox text from Step 2. + +--- + +### Task 2: Section-insertion mechanism, proven with a stub row + +**Files:** +- Create: `plugins/rose-plugs/src/patches/settings.ts` +- Modify: `plugins/rose-plugs/src/index.ts` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `export interface SectionRow { key: string; title: () => string; icon?: any; page: React.ComponentType; }` + - `export default function patchRosiesPlugsSection(getRows: () => SectionRow[]): () => void` — installs both settings-surface patches, returns a cleanup function. `getRows` is called fresh every time the section is rendered, so the row list can change over time (new plugins installed, etc.) without needing a plugin reload. + +This is adapted directly from `plugins/raidens-themes/src/patches/settings.ts` (already live-verified, shipping). The difference: Raiden's Themes finds the "Revenge" section and pushes one row into its existing `settings` array. This inserts a **whole new section object** as a sibling of that section instead. + +- [ ] **Step 1: Write the section-insertion patch** + +`plugins/rose-plugs/src/patches/settings.ts`: +```ts +import { React, NavigationNative } from "@vendetta/metro/common"; +import { findByProps, findByName } from "@vendetta/metro"; +import { after } from "@vendetta/patcher"; +import { Forms } from "@vendetta/ui/components"; +import { findInReactTree } from "@vendetta/utils"; + +const { FormSection, FormRow } = Forms; + +const SECTION_LABEL = "ROSES_PLUGS"; +const SECTION_TITLE = "Rosie's Plugs"; + +const tabsNavigationRef = findByProps("getRootNavigationRef"); +const settingConstants = findByProps("SETTING_RENDERER_CONFIG"); +const createListModule = findByProps("createList"); +const SettingsOverviewScreen = findByName("SettingsOverviewScreen", false); +const TableRowIconModule = findByProps("TableRowIcon"); + +export interface SectionRow { + key: string; + title: () => string; + icon?: any; + page: React.ComponentType; +} + +function navigateToRow(row: SectionRow) { + const navigation = tabsNavigationRef.getRootNavigationRef(); + const Component = row.page; + navigation.navigate("VendettaCustomPage", { + title: row.title(), + render: () => React.createElement(Component), + }); +} + +function buildRendererRow(row: SectionRow) { + return { + type: "pressable", + useTitle: row.title, + title: row.title, + icon: row.icon, + IconComponent: + row.icon && TableRowIconModule && + (() => React.createElement(TableRowIconModule.TableRowIcon, { source: row.icon })), + onPress: () => navigateToRow(row), + withArrow: true, + }; +} + +function Section({ tabs }: { tabs: SectionRow }) { + const navigation = NavigationNative.useNavigation(); + + return React.createElement(FormRow, { + label: tabs.title(), + leading: tabs.icon ? React.createElement(FormRow.Icon, { source: tabs.icon }) : undefined, + trailing: React.createElement(FormRow.Arrow), + onPress: () => { + const Component = tabs.page; + navigation.navigate("VendettaCustomPage", { + title: tabs.title(), + render: () => React.createElement(Component), + }); + }, + }); +} + +function patchPanelUI(getRows: () => SectionRow[], patches: (() => void)[]) { + const target = findByProps("renderTitle", "sections"); + if (!target) return; + + try { + patches.push( + after("default", target, (_: any, ret: any) => { + const UserSettingsOverview = findInReactTree( + ret.props.children, + (n: any) => n.type?.name === "UserSettingsOverview" + ); + + if (UserSettingsOverview) { + patches.push( + after("render", UserSettingsOverview.type.prototype, (_args: any, res: any) => { + const sections = findInReactTree( + res.props.children, + (n: any) => n?.children?.[1]?.type === FormSection + )?.children; + + if (sections) { + const rows = getRows(); + rows.forEach((row, i) => { + sections.splice( + i, + 0, + React.createElement(Section, { key: row.key, tabs: row }) + ); + }); + } + }, true) + ); + } + }, true) + ); + } catch { + // This surface (the tablet/desktop-style settings panel) may not exist on this build at + // all - patchTabsUI below covers the mobile settings list, which is what actually matters. + } +} + +function patchTabsUI(getRows: () => SectionRow[], patches: (() => void)[]) { + if (!settingConstants || !tabsNavigationRef) { + console.warn("[RosePlugs] Missing constants for tabs UI patch"); + return; + } + + let rendererConfigValue = settingConstants.SETTING_RENDERER_CONFIG; + + Object.defineProperty(settingConstants, "SETTING_RENDERER_CONFIG", { + enumerable: true, + configurable: true, + get: () => { + const extra: Record = {}; + for (const row of getRows()) extra[row.key] = buildRendererRow(row); + return { ...rendererConfigValue, ...extra }; + }, + set(v: any) { rendererConfigValue = v; }, + }); + + const firstRender = Symbol("roseplugs-first-render"); + + try { + if (!createListModule) return; + patches.push( + after("createList", createListModule, function (args: any) { + if (args[0][firstRender]) return; + args[0][firstRender] = true; + + const [config] = args; + const sections = config.sections; + const rows = getRows(); + if (!rows.length || !sections) return; + + sections.unshift({ label: SECTION_LABEL, title: SECTION_TITLE, settings: rows.map((r) => r.key) }); + }) + ); + } catch { + if (!SettingsOverviewScreen) return; + patches.push( + after("default", SettingsOverviewScreen, (args: any, ret: any) => { + if (args[0][firstRender]) return; + args[0][firstRender] = true; + + const { sections } = findInReactTree(ret, (i: any) => i.props?.sections)?.props ?? {}; + const rows = getRows(); + if (!rows.length || !sections) return; + + sections.unshift({ label: SECTION_LABEL, title: SECTION_TITLE, settings: rows.map((r) => r.key) }); + }) + ); + } +} + +export default function patchRosiesPlugsSection(getRows: () => SectionRow[]): () => void { + const patches: (() => void)[] = []; + + patchPanelUI(getRows, patches); + patchTabsUI(getRows, patches); + + return () => { + for (const p of patches) p?.(); + }; +} +``` + +- [ ] **Step 2: Wire a stub row into the plugin entry point** + +Replace `plugins/rose-plugs/src/index.ts`: +```ts +import { logger } from "@vendetta"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import Settings from "./ui/Settings"; +import patchRosiesPlugsSection, { SectionRow } from "./patches/settings"; + +let unpatch: (() => void) | undefined; + +function StubPluginsScreen() { + return null; +} + +function buildRows(): SectionRow[] { + return [ + { + key: "ROSES_PLUGS_STUB", + title: () => "Plugins", + icon: getAssetIDByName("SettingsIcon"), + page: StubPluginsScreen, + }, + ]; +} + +export default { + onLoad: () => { + try { + unpatch = patchRosiesPlugsSection(buildRows); + } catch (e: any) { + logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e); + } + }, + onUnload: () => { + unpatch?.(); + unpatch = undefined; + }, + settings: Settings, +}; +``` + +- [ ] **Step 3: Build locally** + +Run: `cd /root/revenge-plugins && npm run build` +Expected: `Successfully built RosePlugs!`, no errors. + +- [ ] **Step 4: Commit, push, deploy, purge** + +```bash +git add plugins/rose-plugs +git commit -m "RosePlugs: insert a new top-level settings section" +git push origin main +``` +Watch the deploy with `gh run watch --exit-status`, then purge the Cloudflare cache (zone `76e85aab7728490e7f6351ac7b8b176f`, same as Task 1 Step 7). + +- [ ] **Step 5: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm connected. +2. On the device, back all the way out of Settings, then reopen it (confirmed sufficient to force a fresh `createList` call — no full app reload needed). +3. Confirm a "Rosie's Plugs" section header appears at the very top of Settings (above Account Settings), with a single "Plugins" row under it — this exact placement was already confirmed live via a scratch `eval` patch before this task was written; this step is re-confirming it against the real committed code. +4. Tap the row — confirm it navigates to a blank screen without crashing (the stub). +5. Use `eval` to double check no duplicate section was inserted: reopen Settings once more and confirm still exactly one "Rosie's Plugs" header, not two — this is what the `firstRender` symbol guard is protecting against. + +--- + +### Task 3: Discover Rosie's other installed plugins + +**Files:** +- Create: `plugins/rose-plugs/src/patches/discoverPlugins.ts` +- Modify: `plugins/rose-plugs/src/index.ts` (temporary logging only, removed in Task 4) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `export interface DiscoveredPlugin { id: string; name: string; icon?: string; settingsComponent: (() => any) | null; }` + - `export function discoverRosiesPlugins(): DiscoveredPlugin[]` — every installed plugin whose manifest lists an author named "Rosie", excluding RosePlugs itself, sorted alphabetically by name. `settingsComponent` is the plugin's own settings component (from `getSettings`) when it has one, otherwise `null`. + +- [ ] **Step 1: Write the discovery function** + +`plugins/rose-plugs/src/patches/discoverPlugins.ts`: +```ts +import { plugins as installedPlugins, getSettings } from "@vendetta/plugins"; + +export interface DiscoveredPlugin { + id: string; + name: string; + icon?: string; + settingsComponent: (() => any) | null; +} + +export function discoverRosiesPlugins(): DiscoveredPlugin[] { + const result: DiscoveredPlugin[] = []; + + for (const id in installedPlugins) { + const plugin = installedPlugins[id]; + const manifest = plugin?.manifest; + if (!manifest || manifest.name === "RosePlugs") continue; + + const authors = manifest.authors ?? []; + if (!authors.some((a: any) => a.name === "Rosie")) continue; + + let settingsComponent: (() => any) | null = null; + try { + const settingsResult = getSettings(id); + if (typeof settingsResult === "function") settingsComponent = settingsResult; + } catch { + settingsComponent = null; + } + + result.push({ id, name: manifest.name, icon: manifest.vendetta?.icon, settingsComponent }); + } + + return result.sort((a, b) => a.name.localeCompare(b.name)); +} +``` + +- [ ] **Step 2: Temporarily log the discovery result** + +In `plugins/rose-plugs/src/index.ts`, add this import and line inside `onLoad`, right after the existing `try {`: +```ts +import { discoverRosiesPlugins } from "./patches/discoverPlugins"; +``` +```ts +console.log( + "[RosePlugs] discovered:", + JSON.stringify(discoverRosiesPlugins().map((p) => ({ id: p.id, name: p.name, hasSettings: !!p.settingsComponent }))) +); +``` + +- [ ] **Step 3: Build, commit, push, deploy, purge** + +Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: discover Rosie's other installed plugins"`. + +- [ ] **Step 4: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm connected. +2. Fully reload the Discord app (so `onLoad` runs fresh and the log fires). +3. `get_logs` — find the `[RosePlugs] discovered:` line and confirm it lists plugins with `manifest.authors` including "Rosie" (compare against the known live list from this session's design phase: role-color-everywhere, message-snippets, custom-user-tags, view-raw, better-timestamps, radial-status, reverse-image-search, you-bar-plus, urban-dictionary, staff-tags, rose-utils, key-inspector, copy-role-color, typing-avatars, pronoun-db, fake-profile-themes-and-effects, server-drawer — the exact live count may have changed since then, so treat that list as "should roughly match," not an exact assertion). +4. Confirm Raiden's Themes and RP Admin are **not** in the list (expected exclusions). +5. Spot check 2-3 entries' `hasSettings` value against whether that plugin actually has a settings screen in Revenge's own Plugins list, to sanity check the `getSettings` typeof-function check is working correctly both ways (true and false cases). + +- [ ] **Step 5: Remove the temporary log line** + +Delete the `console.log("[RosePlugs] discovered:", ...)` call added in Step 2 — its job was proving the discovery function works before wiring it into real UI in Task 4. Leave the import; Task 4 uses it too. + +```bash +git add plugins/rose-plugs/src/index.ts +git commit -m "RosePlugs: remove temporary discovery log" +``` +(No redeploy needed yet — Task 4 will deploy this together with the real row-wiring change.) + +--- + +### Task 4: Wire discovered plugins into real settings rows + +**Files:** +- Modify: `plugins/rose-plugs/src/index.ts` + +**Interfaces:** +- Consumes: `SectionRow` and `patchRosiesPlugsSection` from Task 2's `patches/settings.ts`; `discoverRosiesPlugins` from Task 3's `patches/discoverPlugins.ts`. +- Produces: the real `buildRows()` used for the rest of this plan — "Plugins" (still a stub page until Task 6) followed by one row per discovered plugin that has settings. + +- [ ] **Step 1: Replace the stub row list with real rows** + +Replace `plugins/rose-plugs/src/index.ts`: +```ts +import { logger } from "@vendetta"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import Settings from "./ui/Settings"; +import patchRosiesPlugsSection, { SectionRow } from "./patches/settings"; +import { discoverRosiesPlugins } from "./patches/discoverPlugins"; + +let unpatch: (() => void) | undefined; + +function StubPluginsScreen() { + return null; +} + +function buildRows(): SectionRow[] { + const rows: SectionRow[] = [ + { + key: "ROSES_PLUGS_BROWSER", + title: () => "Plugins", + icon: getAssetIDByName("SettingsIcon"), + page: StubPluginsScreen, + }, + ]; + + for (const plugin of discoverRosiesPlugins()) { + if (!plugin.settingsComponent) continue; + rows.push({ + key: `ROSES_PLUGS_${plugin.id}`, + title: () => plugin.name, + page: plugin.settingsComponent as any, + }); + } + + return rows; +} + +export default { + onLoad: () => { + try { + unpatch = patchRosiesPlugsSection(buildRows); + } catch (e: any) { + logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e); + } + }, + onUnload: () => { + unpatch?.(); + unpatch = undefined; + }, + settings: Settings, +}; +``` + +- [ ] **Step 2: Build, commit, push, deploy, purge** + +Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: replace stub row with discovered plugin settings rows"`. + +- [ ] **Step 3: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm connected. +2. Back out of Settings and reopen it. +3. Confirm "Rosie's Plugs" now lists "Plugins" plus a row for every discovered plugin with settings — names should read like "ServerDrawer", "TypingAvatars", etc., not raw ids or URLs. +4. Tap the "ServerDrawer" row — confirm it opens ServerDrawer's real settings screen (the "Hide the DMs tile" / "Show server names" toggles from earlier this session should be visible). +5. Tap one more plugin's row (pick any with a real settings screen) and confirm it opens correctly too. +6. Tap "Plugins" — still the blank stub for now, confirm no crash. + +--- + +### Task 5: Nexus plugin metadata fetch + cache + +**Files:** +- Create: `plugins/rose-plugs/src/lib/nexusApi.ts` +- Modify: `plugins/rose-plugs/src/index.ts` (temporary logging only, removed in Task 6) + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: + - `export interface NexusPlugin { id: string; name: string; description: string; authors: string[]; category: string; status: string; accent: string; tagline: string; note: string; howItWorks: string; features: string[]; commands: string[]; limitations: string; pageUrl: string; installUrl: string; sourceUrl: string; issueUrl: string; }` + - `export async function fetchNexusPlugins(force?: boolean): Promise` — fetches `https://rp.jarviscli.dev/plugins-data.json`, caches the result in memory for the session, dedupes concurrent calls into a single in-flight request. `force: true` bypasses the cache. + - `export function clearNexusCache(): void` — drops the in-memory cache so the next `fetchNexusPlugins()` call re-fetches. + +- [ ] **Step 1: Write the fetch/cache module** + +`plugins/rose-plugs/src/lib/nexusApi.ts`: +```ts +export interface NexusPlugin { + id: string; + name: string; + description: string; + authors: string[]; + category: string; + status: string; + accent: string; + tagline: string; + note: string; + howItWorks: string; + features: string[]; + commands: string[]; + limitations: string; + pageUrl: string; + installUrl: string; + sourceUrl: string; + issueUrl: string; +} + +const NEXUS_URL = "https://rp.jarviscli.dev/plugins-data.json"; + +let cache: NexusPlugin[] | null = null; +let inflight: Promise | null = null; + +export async function fetchNexusPlugins(force = false): Promise { + if (!force && cache) return cache; + if (!force && inflight) return inflight; + + inflight = fetch(NEXUS_URL) + .then((res) => { + if (!res.ok) throw new Error(`Nexus fetch failed: ${res.status}`); + return res.json(); + }) + .then((data: NexusPlugin[]) => { + cache = data; + inflight = null; + return data; + }) + .catch((e) => { + inflight = null; + throw e; + }); + + return inflight; +} + +export function clearNexusCache(): void { + cache = null; +} +``` + +- [ ] **Step 2: Temporarily log a live fetch result** + +In `plugins/rose-plugs/src/index.ts`, add this import: +```ts +import { fetchNexusPlugins } from "./lib/nexusApi"; +``` +And this line inside `onLoad`, after the existing patch call: +```ts +fetchNexusPlugins() + .then((list) => console.log("[RosePlugs] nexus fetch ok:", list.length, JSON.stringify(list[0]))) + .catch((e) => console.log("[RosePlugs] nexus fetch failed:", String(e))); +``` + +- [ ] **Step 3: Build, commit, push, deploy, purge** + +Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: fetch and cache Nexus plugin metadata"`. + +- [ ] **Step 4: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm connected. +2. Fully reload the Discord app. +3. `get_logs` — find `[RosePlugs] nexus fetch ok:` with a real count (should roughly match the number of plugins in this repo, currently around 20) and a real first plugin's JSON (recognizable `name`/`category`/`installUrl` fields, not placeholder or malformed data). If it logs `nexus fetch failed` instead, that's a real bug to fix before moving on — this confirms `fetch` actually works from inside the RN app against this specific host, not just in theory. + +- [ ] **Step 5: Remove the temporary log lines** + +Delete the `fetchNexusPlugins().then(...).catch(...)` block added in Step 2. Leave the import — Task 6 uses it. + +```bash +git add plugins/rose-plugs/src/index.ts +git commit -m "RosePlugs: remove temporary nexus fetch log" +``` +(No redeploy needed yet — Task 6 deploys this together with the real browser UI.) + +--- + +### Task 6: Quick-install browser UI + +**Files:** +- Create: `plugins/rose-plugs/src/ui/PluginDetailSheet.tsx` +- Create: `plugins/rose-plugs/src/ui/PluginsBrowser.tsx` +- Modify: `plugins/rose-plugs/src/index.ts` + +**Interfaces:** +- Consumes: `NexusPlugin`/`fetchNexusPlugins` from Task 5's `lib/nexusApi.ts`; `plugins`/`installPlugin` from `@vendetta/plugins`; `showCustomAlert` from `@vendetta/ui/alerts`; `showToast` from `@vendetta/ui/toasts`; `PrimaryButton`/`TableRow`/`TableRowGroup`/`SettingsScaffold`/`NoteBox` from `@shared/ui/*`. +- Produces: `PluginsBrowser` (default export, no props) and `PluginDetailSheet` (default export, props `{ plugin: NexusPlugin }`) — replaces `StubPluginsScreen` as the "Plugins" row's page. + +- [ ] **Step 1: Write the detail/install sheet** + +`plugins/rose-plugs/src/ui/PluginDetailSheet.tsx`: +```tsx +import { React, ReactNative } from "@vendetta/metro/common"; +import { plugins as installedPlugins, installPlugin } from "@vendetta/plugins"; +import { showToast } from "@vendetta/ui/toasts"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import { NexusPlugin } from "../lib/nexusApi"; + +const { ScrollView, Text } = ReactNative; + +export default function PluginDetailSheet({ plugin }: { plugin: NexusPlugin }) { + const [installing, setInstalling] = React.useState(false); + const alreadyInstalled = plugin.installUrl in installedPlugins; + + const onInstall = React.useCallback(() => { + setInstalling(true); + installPlugin(plugin.installUrl) + .then(() => { + setInstalling(false); + showToast(`${plugin.name} installed`); + }) + .catch((e: any) => { + setInstalling(false); + showToast(`Install failed: ${e?.message ?? e}`); + }); + }, [plugin.installUrl]); + + return ( + + {plugin.name} + {plugin.category} + {plugin.tagline || plugin.description} + {plugin.status && plugin.status !== "default" && ( + + {plugin.status.toUpperCase()} + + )} + + + ); +} +``` + +- [ ] **Step 2: Write the browser screen** + +`plugins/rose-plugs/src/ui/PluginsBrowser.tsx`: +```tsx +import { React, ReactNative } from "@vendetta/metro/common"; +import { plugins as installedPlugins } from "@vendetta/plugins"; +import { showCustomAlert } from "@vendetta/ui/alerts"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import NoteBox from "@shared/ui/NoteBox"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import { TableRow, TableRowGroup } from "@shared/ui/table"; +import { fetchNexusPlugins, NexusPlugin } from "../lib/nexusApi"; +import PluginDetailSheet from "./PluginDetailSheet"; + +const { Text } = ReactNative; + +interface BrowserState { + loading: boolean; + error: string | null; + plugins: NexusPlugin[]; +} + +export default function PluginsBrowser() { + const [state, setState] = React.useState({ loading: true, error: null, plugins: [] }); + + const load = React.useCallback(() => { + setState((s) => ({ ...s, loading: true, error: null })); + fetchNexusPlugins() + .then((plugins) => setState({ loading: false, error: null, plugins })) + .catch((e) => setState({ loading: false, error: String(e?.message ?? e), plugins: [] })); + }, []); + + React.useEffect(() => { + load(); + }, [load]); + + if (state.loading) { + return ( + + Loading Rosie's plugins… + + ); + } + + if (state.error) { + return ( + + Couldn't load the plugin list: {state.error} + + + ); + } + + const byCategory = new Map(); + for (const plugin of state.plugins) { + const list = byCategory.get(plugin.category) ?? []; + list.push(plugin); + byCategory.set(plugin.category, list); + } + + return ( + + {[...byCategory.entries()].map(([category, plugins]) => ( + + {plugins.map((plugin) => { + const installed = plugin.installUrl in installedPlugins; + return ( + + {plugin.status.toUpperCase()} + + ) : installed ? ( + ✓ + ) : undefined + } + onPress={() => showCustomAlert(PluginDetailSheet, { plugin })} + /> + ); + })} + + ))} + + ); +} +``` + +- [ ] **Step 3: Wire the real browser into the section, remove the stub** + +In `plugins/rose-plugs/src/index.ts`: remove the `fetchNexusPlugins` import and the temporary log block if not already removed in Task 5 Step 5; remove the `StubPluginsScreen` function; import and use the real screen instead: +```ts +import { logger } from "@vendetta"; +import { getAssetIDByName } from "@vendetta/ui/assets"; +import Settings from "./ui/Settings"; +import PluginsBrowser from "./ui/PluginsBrowser"; +import patchRosiesPlugsSection, { SectionRow } from "./patches/settings"; +import { discoverRosiesPlugins } from "./patches/discoverPlugins"; + +let unpatch: (() => void) | undefined; + +function buildRows(): SectionRow[] { + const rows: SectionRow[] = [ + { + key: "ROSES_PLUGS_BROWSER", + title: () => "Plugins", + icon: getAssetIDByName("SettingsIcon"), + page: PluginsBrowser, + }, + ]; + + for (const plugin of discoverRosiesPlugins()) { + if (!plugin.settingsComponent) continue; + rows.push({ + key: `ROSES_PLUGS_${plugin.id}`, + title: () => plugin.name, + page: plugin.settingsComponent as any, + }); + } + + return rows; +} + +export default { + onLoad: () => { + try { + unpatch = patchRosiesPlugsSection(buildRows); + } catch (e: any) { + logger.error("[RosePlugs] Failed to patch settings:", e?.message ?? e); + } + }, + onUnload: () => { + unpatch?.(); + unpatch = undefined; + }, + settings: Settings, +}; +``` + +- [ ] **Step 4: Build, commit, push, deploy, purge** + +Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: build the quick-install browser"`. + +- [ ] **Step 5: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm connected. +2. Back out of Settings and reopen it, navigate to Rosie's Plugs → Plugins. +3. Confirm real category headers and plugin rows render (not blank), matching the live Nexus JSON (cross-check with a fresh `eval` call doing `fetch("https://rp.jarviscli.dev/plugins-data.json").then(r => r.json())` and comparing counts/names). +4. Confirm already-installed plugins show a green ✓ or "Installed" sublabel; confirm any non-default `status` values show as an uppercase badge. +5. Tap a plugin row — confirm `PluginDetailSheet` opens with the right name/category/tagline. +6. Find a plugin in the Nexus catalog that is **not** in this device's installed list (compare against the live `plugins` record from `@vendetta/plugins`) and tap Install on it. Confirm the button shows "Installing…" then success, and via `eval` confirm `plugin.installUrl in window.vendetta.plugins.plugins` is now `true`. If every catalog plugin happens to already be installed on this test device, install-testing an already-installed one is acceptable too — the goal is confirming `installPlugin` fires without error and the button states transition correctly, not necessarily a net-new install. + +--- + +### Task 7: RosePlugs' own settings screen + +**Files:** +- Modify: `plugins/rose-plugs/src/ui/Settings.tsx` + +**Interfaces:** +- Consumes: `clearNexusCache` from Task 5's `lib/nexusApi.ts`. +- Produces: nothing new consumed elsewhere — this is RosePlugs' own settings screen, reached normally via Revenge's Plugins list (not via a row inside Rosie's Plugs itself). + +- [ ] **Step 1: Add the refresh button** + +Replace `plugins/rose-plugs/src/ui/Settings.tsx`: +```tsx +import { React } from "@vendetta/metro/common"; +import SettingsScaffold from "@shared/ui/SettingsScaffold"; +import NoteBox from "@shared/ui/NoteBox"; +import PrimaryButton from "@shared/ui/PrimaryButton"; +import { showToast } from "@vendetta/ui/toasts"; +import { clearNexusCache } from "../lib/nexusApi"; + +export default function Settings() { + return ( + + + RosePlugs gathers Rosie's other plugins under their own "Rosie's Plugs" section in + Settings, with a quick-install browser for anything not installed yet. + + { + clearNexusCache(); + showToast("Plugin list will refresh next time you open Plugins"); + }} + style={{ margin: 16 }} + /> + + ); +} +``` + +- [ ] **Step 2: Build, commit, push, deploy, purge** + +Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: add refresh button to its own settings"`. + +- [ ] **Step 3: Live-verify** + +Via `revenge-devtools`: +1. `devtools_clients` — confirm connected. +2. Open Settings → Plugins (Revenge's own list) → RosePlugs → Settings. +3. Confirm the NoteBox text and "Refresh plugin list" button render. +4. Open Plugins (Rosie's Plugs → Plugins) once first, to populate the cache, then go back to RosePlugs' own settings and tap "Refresh plugin list" — confirm the toast appears. +5. Reopen Rosie's Plugs → Plugins and confirm it still loads correctly (a fresh fetch after the cache-bust, not a crash or stale/empty state). + +--- + +### Task 8: Nexus metadata entry and final end-to-end sweep + +**Files:** +- Modify: `site/meta.json` + +**Interfaces:** +- Consumes: nothing new. +- Produces: nothing new — this is polish plus a final full-system verification pass. + +- [ ] **Step 1: Add a `site/meta.json` entry for rose-plugs** + +Open `site/meta.json`, add an entry keyed `"rose-plugs"` alongside the existing entries, following the same shape as the other entries read in Task's earlier exploration (`category`, `status`, `accent`, `tagline`, `note`, `howItWorks`, `features`, `commands`, `limitations` — all optional, defaults exist in `scripts/generate-site.mjs` for anything omitted): +```json +"rose-plugs": { + "category": "Utility", + "status": "new", + "accent": "#F47FFF", + "tagline": "A home of Rosie's own in Settings, with one-tap installs for anything not installed yet.", + "howItWorks": "Adds a \"Rosie's Plugs\" section to Discord's Settings, separate from Revenge's own Plugins list. Its first entry, \"Plugins,\" is a quick-install browser pulling live from this site's own plugin catalog. Every other Rosie-authored plugin that's currently installed gets a direct settings shortcut listed right below it.", + "features": [ + "New top-level \"Rosie's Plugs\" settings section", + "One-tap install browser for every plugin in this catalog, grouped by category", + "Direct settings shortcuts for each installed Rosie-authored plugin - no digging through Revenge's own Plugins list" + ], + "commands": [], + "limitations": "Only surfaces plugins whose manifest lists \"Rosie\" as an author - a plugin credited to someone else won't be picked up automatically." +} +``` +Adjust `category`/`accent` if inconsistent with this repo's existing category naming once you read the full `site/meta.json` file - match whatever categories already exist rather than inventing a new one, unless none fit. + +- [ ] **Step 2: Build, commit, push, deploy, purge** + +Same sequence as Task 2 Step 3-4. Commit message: `"RosePlugs: add Nexus site metadata"`. + +- [ ] **Step 3: Full end-to-end live-verify** + +Via `revenge-devtools`, working through the spec's testing checklist in full: +1. `devtools_clients` — confirm connected. +2. "Rosie's Plugs" section appears in Settings, correctly positioned and titled. +3. "Plugins" row opens the browser with real, categorized, correctly-badged data. +4. Installing a not-yet-installed plugin actually installs it (repeat Task 6 Step 5's install check if not already covered by a genuinely new plugin at that point). +5. Every already-installed Rosie-authored plugin with settings shows a row that opens the correct screen (spot-check at least 3, not just the 1-2 checked in Task 4). +6. Confirm no regression: open Revenge's own Plugins list, confirm it still lists and manages every plugin normally; open one non-Rosie plugin's settings (e.g. anything from a different author in the installed list) and confirm it still works unaffected. +7. Visit `https://rp.jarviscli.dev/rose-plugs/` in a browser and confirm the Nexus site renders RosePlugs' own page correctly with the Task 8 Step 1 metadata.