diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e139e26..f5016f9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,14 +5,14 @@ }, "metadata": { "description": "Orchestrator skill for RHDH plugin development - onboard, update, and maintain plugins in the Extensions Catalog", - "version": "0.6.0" + "version": "0.7.0" }, "plugins": [ { "name": "rhdh", "source": "./", "description": "Skills for RHDH plugin lifecycle management", - "version": "0.6.0", + "version": "0.7.0", "strict": true } ] diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index eadcf3d..f4ba7f1 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "rhdh", "description": "All-in-one toolkit for Red Hat Developer Hub (RHDH). Covers plugin development, overlay management, environment setup, version compatibility, CI/CD, and RHDH ecosystem navigation.", - "version": "0.6.0", + "version": "0.7.0", "author": { "name": "RHDH Store Manager" }, diff --git a/pyproject.toml b/pyproject.toml index 3d5653d..e50f84b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "rhdh-skill" -version = "0.6.0" +version = "0.7.0" description = "Claude Code skill for RHDH plugin development" readme = "README.md" license = "Apache-2.0" diff --git a/skills/rhdh-coding/SKILL.md b/skills/rhdh-coding/SKILL.md new file mode 100644 index 0000000..cd48c29 --- /dev/null +++ b/skills/rhdh-coding/SKILL.md @@ -0,0 +1,241 @@ +--- +name: rhdh-coding +description: >- + Backstage and RHDH plugin development patterns. Use when writing, modifying, + or reviewing code in a Backstage or RHDH plugin — frontend components, backend + services, API clients, styling, testing, entity pages, scaffolder actions, + catalog processors, NFS Blueprints, dynamic plugin configuration. Also use + when asked to "add a feature to a plugin", "implement a Backstage component", + "create an API client", "write plugin tests", "add a backend route", "create + a scaffolder action", "what plugin type should I use", or any coding task in + a Backstage or RHDH codebase. +--- + +# RHDH Coding + +Patterns for Backstage and RHDH plugin development that agents need but can't +reliably get from training data or codebase discovery alone. This covers what +you'd learn after six months of getting burned by non-obvious conventions. + +## Before You Write Code + +### 1. Check for existing specs + +Look for a spec, PRD, or OpenSpec design for this work — in `docs/plans/**/`, +`specifications/`, `openspec/changes/*/`, or linked from the issue. If found, +use the component list and acceptance criteria as your implementation blueprint. +Read `references/frontend-specs.md` for what good frontend specs include. + +### 2. Discover the plugin context + +Run the detection script to understand what you're working with: + +```bash +python scripts/detect-rhdh-context.py --path +``` + +This reports: Backstage role, frontend system (legacy/NFS/dual), existing +extensions, MUI version, dynamic plugin status, plugin ID, scalprum name. + +### 3. Read workspace instructions + +```bash +test -f AGENTS.md && cat AGENTS.md +test -f CLAUDE.md && cat CLAUDE.md +``` + +These contain repo-specific rules that override general patterns. + +### 4. Check version compatibility + +Consult `../rhdh/references/versions.md` for the RHDH → Backstage version +matrix. Your `@backstage/*` dependency versions must match the target RHDH +version. Mismatched versions cause runtime errors — most commonly "Cannot +read properties of undefined." + +## Styling: BUI First + +**For new plugins,** use Backstage UI (`@backstage/ui`) with CSS Modules and +BUI CSS variables. **In existing plugins,** match whatever the workspace already +uses — if it's MUI v4, stay consistent rather than mixing libraries. Only +introduce BUI in a workspace that has already adopted it or is actively migrating. + +**Priority for new plugins:** +1. **BUI** (`@backstage/ui`) — default for new plugins and new workspaces +2. **MUI v5** (`@mui/material`) — when BUI lacks the component you need +3. **MUI v4** (`@material-ui/core`) — legacy maintenance only + +When using MUI v5 alongside BUI, add the class name generator to prevent +collisions in dynamic plugin bundles: + +```typescript +// src/index.ts +import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; +ClassNameGenerator.configure(name => name.startsWith('v5-') ? name : `v5-${name}`); +``` + +**Icons:** Use `@remixicon/react` (not `@material-ui/icons`). + +Read `references/bui.md` for the component mapping table and CSS variable reference. + +## Frontend System: NFS for New Plugins + +New plugins targeting RHDH 1.5+ should use the **new frontend system (NFS)** +with Blueprints (`createFrontendPlugin`, `PageBlueprint`, `EntityCardBlueprint`, +etc.). + +Legacy system (`createPlugin` + `createRoutableExtension`) is for existing +plugins not yet migrated. For migration, use the `nfs-migration` sibling skill. + +Read `references/nfs.md` for Blueprint patterns, alpha export structure, and +compatWrapper decisions. + +## Plugin Types + +Not sure whether to build a page, a card, an entity tab, or a backend module? +Read `references/plugin-types.md` for the decision guide. + +## Backend: New System Only + +All backend code MUST use: +- `createBackendPlugin` — new standalone backend capabilities +- `createBackendModule` — extensions to existing plugins (catalog, scaffolder, auth) + +From `@backstage/backend-plugin-api`. Never the legacy backend system. + +Core services: `httpRouter`, `logger`, `rootConfig`, `httpAuth`, `database`, +`scheduler`, `permissions`, `discovery`. + +**Default export is required** from the entry point (`src/index.ts`). Missing +default export is the #1 cause of "plugin not loading" in RHDH. + +## RHDH Dynamic Plugins + +Key gotchas (read `references/rhdh.md` for full details): + +- **Default export required** from `src/index.ts` — missing this is the #1 cause of "plugin not loading" +- **Scalprum name** must match the key in `dynamic-plugins.yaml` wiring (derived from package name) +- **MUI v5 class name generator** required when using `@mui/material` in dynamic bundles +- **Auth:** use `fetchApi` — it includes auth headers automatically. Don't implement custom auth. +- **RHDH-only Blueprints:** `AppDrawerContentBlueprint`, `GlobalHeaderMenuItemBlueprint` — not upstream + +`references/rhdh.md` covers all RHDH-specific patterns including backend modules, +extension points, theming, i18n, and the common package pattern. + +## Analytics + +BUI components (`Link`, `ButtonLink`, `Tab`, `MenuItem`, `Tag`, `Table` rows) +have built-in click analytics via the Backstage Analytics API. Don't add manual +`captureEvent('click', ...)` for these — it produces duplicates. Use `noTrack` +prop to suppress the built-in event only when replacing it with a domain-specific +verb (e.g., `deploy`, `approve`). For detailed instrumentation guidance, install +the official `plugin-analytics-instrumentation` skill from backstage.io. + +## Testing + +Backstage has its own test infrastructure that differs from standard React testing. +Read `references/testing.md` for: TestApiProvider setup, renderInTestApp, +entity context mocking, async component testing, accessibility testing, and +common gotchas. + +## Before You Commit + +Run these commands **in order** from the workspace root (e.g., `workspaces/boost/`). +Stop on first failure — fix it before continuing. This sequence catches every CI +gate locally. + +```bash +yarn prettier:fix # format code +yarn tsc:full # full TypeScript type check +yarn build:all # build all packages in the workspace +yarn test --watchAll=false # run tests (disable watch mode) +yarn build:api-reports:only # generate/update API report files +``` + +### API reports + +`build:api-reports:only` generates `report.api.md` files for packages with +public exports. These files **must be committed** — CI checks them. All public +exports need `/** @public */` JSDoc with a description (not just the bare tag). + +### Changesets + +Changesets are required for published package changes. Rules: +- Only cover plugins under `plugins/` — **never `packages/*`** (those are + private app/backend packages that are never published) +- Only include a plugin if it has changes in `src/` or other published paths + (root `index.ts`, `config.d.ts`, `package.json`) +- Changes only in `dev/`, `tests/`, `__fixtures__/`, or stories do NOT need + a changeset for that plugin +- Write the changeset file directly to `/.changeset/.md` — + don't run `yarn changeset` interactively + +### Commits + +All commits must be signed off (`git commit -s`) per DCO requirements. + +## Reference Index + +| Reference | Load when... | +|-----------|-------------| +| `references/frontend-specs.md` | Writing specs, PRDs, or OpenSpec proposals for frontend features | +| `references/bui.md` | Using BUI components — mapping table, CSS variables, icons | +| `references/plugin-types.md` | Deciding what type of plugin or extension to build | +| `references/nfs.md` | Writing NFS code — Blueprints, package exports, compatWrapper | +| `references/dev-app.md` | Plugin dev mode, full Backstage app setup, sidebar, app-config | +| `references/testing.md` | Writing tests — TestApiProvider, entity mocking, a11y | +| `references/rhdh.md` | RHDH-specific patterns — dynamic plugins, backend modules, i18n | + +## Sibling Skills (rhdh-skill) + +| Skill | Use when... | +|-------|------------| +| `create-plugin` | Scaffolding a new plugin from scratch | +| `nfs-migration` | Migrating an existing plugin from legacy to NFS | +| `overlay` | Managing overlay packaging for the Extensions Catalog | +| `backstage-upgrade` | Upgrading Backstage dependency versions | +| `rhdh-local` | Running and testing plugins locally | +| `rhdh` | RHDH version matrix, repo navigation, ecosystem context | + +## Ecosystem Skills (skills.sh) + +These open-source skills complement rhdh-coding with generic frontend quality +patterns. Install into `.fullsend/customized/skills/` or your local skills +directory. + +**Essential:** + +| Skill | Source | What it adds | +|-------|--------|-------------| +| `ui-ux-pro-max` | nextlevelbuilder/ui-ux-pro-max-skill | 99 UX rules — accessibility, touch targets, animation, forms, navigation | +| `frontend-design` | anthropics/skills | Design thinking, anti-AI-aesthetic, microcopy quality | +| `webapp-testing` | anthropics/skills | Playwright-based browser testing, server lifecycle | + +**Recommended:** + +| Skill | Source | What it adds | +|-------|--------|-------------| +| `design-taste-frontend` | leonxlnx/taste-skill | Anti-slop discipline, design inference | +| `emil-design-eng` | emilkowalski/skills | Animation decisions, CSS polish | + +```bash +npx skills add https://github.com/nextlevelbuilder/ui-ux-pro-max-skill --skill ui-ux-pro-max +npx skills add https://github.com/anthropics/skills --skill frontend-design +npx skills add https://github.com/anthropics/skills --skill webapp-testing +npx skills add https://github.com/leonxlnx/taste-skill --skill taste-skill +npx skills add https://github.com/emilkowalski/skills --skill emil-design-eng +``` + +## Official Backstage Skills (backstage.io) + +Install via `npx skills add https://backstage.io`. These cover migration and +instrumentation workflows that this skill does not. + +| Skill | Use when... | +|-------|------------| +| `mui-to-bui-migration` | Migrating a plugin from MUI to BUI (component-by-component guide) | +| `plugin-new-frontend-system-support` | Adding NFS support while keeping legacy working (dual entry point) | +| `plugin-full-frontend-system-migration` | Fully migrating a plugin to NFS, dropping legacy | +| `app-frontend-system-migration` | Migrating an entire Backstage app to the new frontend system | +| `plugin-analytics-instrumentation` | Adding analytics events via Backstage Analytics API | +| `onboard-to-openapi-server` | Migrating backend router to typed OpenAPI tooling | diff --git a/skills/rhdh-coding/references/bui.md b/skills/rhdh-coding/references/bui.md new file mode 100644 index 0000000..3cf4f5a --- /dev/null +++ b/skills/rhdh-coding/references/bui.md @@ -0,0 +1,149 @@ +# Backstage UI (BUI) Reference + +Setup: `yarn add @backstage/ui @remixicon/react` and add `import '@backstage/ui/css/styles.css'` to `src/index.ts`. + +BUI uses **CSS Modules** with CSS custom properties — not makeStyles or CSS-in-JS. + +```css +/* MyComponent.module.css */ +@layer components { + .container { + padding: var(--bui-space-4); + background-color: var(--bui-bg-surface-1); + border-radius: var(--bui-radius-2); + } +} +``` + +```tsx +import { Box, Text } from '@backstage/ui'; +import styles from './MyComponent.module.css'; + +export const MyComponent = () => ( + + Title + +); +``` + +## Component Mapping + +| BUI | Replaces MUI | Key differences | +|-----|-------------|-----------------| +| `Text` | `Typography` | `variant`: title-large/medium/small/x-small, body-large/medium/small/x-small. `weight`, `truncate` props. | +| `Button` | `Button` | `variant="primary"/"secondary"/"tertiary"`, `isDisabled`, `destructive`, `loading` | +| `ButtonIcon` | `IconButton` | `icon={}`, `onPress` (not `onClick`), needs `aria-label` | +| `Card` + `CardHeader/Body/Footer` | `Paper`, `Card` | Composition pattern | +| `Flex` | `Box display="flex"` | `direction`, `align`, `justify="between"` (not `"space-between"`) | +| `Grid.Root` + `Grid.Item` | `Grid container/item` | `columns={{ sm: '12' }}`, `colSpan={{ sm: '12', md: '6' }}` | +| `TextField` | `TextField` | `isRequired`, `onChange` receives string directly (not event!) | +| `PasswordField` | — | Password input with show/hide toggle | +| `Dialog` + `DialogTrigger` | `Dialog` | Trigger pattern | +| `Tabs` + `TabList/Tab/TabPanel` | `Tabs/TabList/TabPanel` | `defaultSelectedKey`, id-based | +| `Menu` + `MenuTrigger/MenuItem` | `Menu/Popover` | Trigger pattern. Also: `MenuSection`, `MenuSeparator`, `SubmenuTrigger` | +| `Tooltip` + `TooltipTrigger` | `Tooltip` | Both imported from `@backstage/ui` | +| `Tag` | `Chip` | Direct replacement | +| `TagGroup` | — | Grouped tags | +| `Select` | `Select` | Single and multiple selection modes | +| `Switch` | `Switch` | Toggle | +| `Checkbox` | `Checkbox` | Checkbox input | +| `CheckboxGroup` | — | Grouped checkboxes with shared label, `orientation`, `isRequired` | +| `RadioGroup` + `Radio` | `RadioGroup` | BUI pattern | +| `SearchField` | `InputBase` | Search input | +| `SearchAutocomplete` | — | Search with autocomplete popover (`SearchAutocompleteItem`) | +| `Skeleton` | `Skeleton` | Loading placeholder | +| `Accordion` + `AccordionTrigger/Panel` | `Accordion` | Trigger pattern. `AccordionGroup` for multiple. | +| `Alert` | `@material-ui/lab Alert` | `status`, `title`, `description` props | +| `Badge` | — | Inline badge/label with optional icon | +| `DateRangePicker` | — | Date range input field | +| `FieldLabel` | — | Form field label with description and secondary label | +| `Header` | — | Page header with breadcrumbs and tabs | +| `PluginHeader` | — | Plugin-level header (used by NFS PageLayout automatically) | +| `List` + `ListRow` | `List/ListItem` | BUI list pattern | +| `Slider` | — | Range slider input | +| `Table` + `useTable` | `Table` | Data tables with `useTable` hook (supports `complete`, `offset`, `cursor` pagination) | +| `TablePagination` | — | Standalone pagination component | +| `FullPage` | — | Full-page layout wrapper | +| `Container` | — | Centered content container with max-width | +| `VisuallyHidden` | — | Accessibility helper | + +## Icons + +Use `@remixicon/react` — not `@material-ui/icons`. + +```tsx +import { RiSearchLine, RiCloseLine } from '@remixicon/react'; + +``` + +| MUI Icon | Remix Icon | +|----------|------------| +| Close | RiCloseLine | +| Search | RiSearchLine | +| Settings | RiSettingsLine | +| Add | RiAddLine | +| Delete | RiDeleteBinLine | +| Edit | RiEditLine | +| Check | RiCheckLine | +| Error | RiErrorWarningLine | +| Warning | RiAlertLine | +| Info | RiInformationLine | +| ExpandMore | RiArrowDownSLine | +| ChevronRight | RiArrowRightSLine | +| Menu | RiMenuLine | +| MoreVert | RiMore2Line | +| Visibility | RiEyeLine | + +Full catalog: https://remixicon.com/ + +## CSS Variables + +| Category | Variables | +|----------|----------| +| Spacing | `--bui-space-1` (4px) … `--bui-space-8` (32px) | +| Foreground | `--bui-fg-primary`, `--bui-fg-secondary`, `--bui-fg-link`, `--bui-fg-danger` | +| Background | `--bui-bg-surface-0` (page), `--bui-bg-surface-1` (card), `--bui-bg-hover`, `--bui-bg-solid` | +| Border | `--bui-border`, `--bui-ring` | +| Radius | `--bui-radius-2`, `--bui-radius-3`, `--bui-radius-full` | +| Typography | `--bui-font-regular`, `--bui-font-size-1/2/3`, `--bui-font-weight-regular/bold` | + +## MUI Spacing → BUI Spacing + +| `theme.spacing(n)` | BUI variable | +|--------------------|-------------| +| `theme.spacing(0.5)` | `var(--bui-space-1)` | +| `theme.spacing(1)` | `var(--bui-space-2)` | +| `theme.spacing(2)` | `var(--bui-space-4)` | +| `theme.spacing(3)` | `var(--bui-space-6)` | +| `theme.spacing(4)` | `var(--bui-space-8)` | + +## MUI Colors → BUI Colors + +| `theme.palette.*` | BUI variable | +|-------------------|-------------| +| `text.primary` | `var(--bui-fg-primary)` | +| `text.secondary` | `var(--bui-fg-secondary)` | +| `background.paper` | `var(--bui-bg-surface-1)` | +| `background.default` | `var(--bui-bg-surface-0)` | +| `error.main` | `var(--bui-fg-danger)` | +| `divider` | `var(--bui-border)` | + +## Responsive + +```tsx +import { useBreakpoint } from '@backstage/ui'; +const breakpoint = useBreakpoint(); // 'sm' | 'md' | 'lg' | 'xl' +``` + +## Known Limitations + +BUI does not yet have: Timeline, Pagination, Alert, Autocomplete. +Use MUI v5 (`@mui/material`) for these — add the class name generator when mixing. + +Some Backstage APIs (e.g., NavItemBlueprint `icon` prop) expect MUI `IconComponent` +type. Remix icons aren't type-compatible — use MUI icons for these specific cases. + +## Further Reference + +- BUI docs: https://ui.backstage.io +- Full migration guide: `mui-to-bui-migration` skill in community-plugins diff --git a/skills/rhdh-coding/references/dev-app.md b/skills/rhdh-coding/references/dev-app.md new file mode 100644 index 0000000..1fc3072 --- /dev/null +++ b/skills/rhdh-coding/references/dev-app.md @@ -0,0 +1,212 @@ +# Dev App Setup + +Two ways to test a plugin locally: + +1. **Plugin dev mode** (`dev/index.tsx`) — lightweight, plugin-scoped, no backend. + Use for component development and quick iteration. +2. **Full Backstage app** (`packages/app` + `packages/backend`) — complete + environment with catalog, auth, and backend services. Use when the plugin + needs real data, API calls, or integration testing with other plugins. + +--- + +## Plugin Dev Mode + +Each plugin has a `dev/` directory with a standalone dev harness. Start it with +`yarn start` from the plugin directory. + +```tsx +// plugins/my-plugin/dev/index.tsx +import { createDevApp } from '@backstage/dev-utils'; +import { getAllThemes } from '@red-hat-developer-hub/backstage-plugin-theme'; +import { myPlugin, MyPage } from '../src'; + +createDevApp() + .registerPlugin(myPlugin) + .addPage({ + element: , + title: 'My Plugin', + path: '/my-plugin', + }) + .addThemes(getAllThemes()) + .render(); +``` + +- No backend needed — mock data or stub APIs inline +- Hot reload on source changes +- Good for: UI development, component styling, layout iteration +- Limited: no real catalog entities, no auth, no backend API calls + +For NFS plugins, use the NFS dev app pattern: + +```tsx +// plugins/my-plugin/dev/index.tsx +import { createApp } from '@backstage/frontend-defaults'; +import myPlugin from '../src'; + +const app = createApp({ + features: [myPlugin], + configLoader: async () => ({ + config: [{ data: { app: { packages: 'all' } } }], + }), +}); + +export default app.createRoot(); +``` + +--- + +## Full Backstage App + +A complete Backstage instance in `packages/app` + `packages/backend`. Use when +the plugin needs real backend services, catalog data, or integration with other +plugins. + +### createApp (NFS) + +```tsx +// packages/app/src/App.tsx +import { createApp } from '@backstage/frontend-defaults'; + +const app = createApp({ + features: [ + // Explicitly imported plugins go here (optional with auto-discovery) + ], +}); + +export default app.createRoot(); +``` + +## Auto-Discovery with app.packages + +Enable automatic plugin discovery in `app-config.yaml`: + +```yaml +app: + packages: all +``` + +With this set, any plugin added as a `package.json` dependency that exports an +NFS plugin is automatically detected and loaded — no manual imports needed. + +**Caveat: translation modules are NOT auto-discovered.** Modules with +`pluginId: 'app'` (like translation modules) must be explicitly imported or +re-exported as a separate entry point. See "Translation Module Entry Points" +below. + +## Sidebar Navigation + +NFS uses `NavContentBlueprint` for sidebar layout — not manual `SidebarItem` lists. + +```tsx +import { NavContentBlueprint } from '@backstage/frontend-plugin-api'; + +const sidebarContent = NavContentBlueprint.make({ + name: 'main-nav', + params: { + content: nav => ( + <> + {nav.take('page:my-plugin')} + {nav.take('page:catalog')} + {nav.rest()} + + ), + }, +}); +``` + +- `nav.take('page:xxx')` — renders a specific page's nav item in this position +- `nav.rest()` — renders all remaining nav items not explicitly placed +- Pages appear in the sidebar automatically when they have `title` and `icon` + set on `PageBlueprint` or `createFrontendPlugin` + +**SidebarItem icon prop:** Expects a component reference, not a render function. +Use `icon: RiHomeLine` (the component), not `icon: () => `. + +## Sign-In Page + +For local development with guest auth: + +```tsx +import { SignInPageBlueprint } from '@backstage/frontend-plugin-api'; + +const signInPage = SignInPageBlueprint.make({ + params: { + provider: { + id: 'guest', + title: 'Guest', + message: 'Sign in as guest', + }, + }, +}); +``` + +Add to the app's features. + +## Default Route + +Configure which page loads at `/` in `app-config.yaml`: + +```yaml +app: + routes: + root: /my-plugin +``` + +Or redirect from root: + +```yaml +app: + routes: + bindings: + - path: / + redirect: /my-plugin +``` + +## Catalog Locations (file: paths) + +```yaml +catalog: + locations: + - type: file + target: ../../workspaces/my-workspace/catalog-info.yaml +``` + +**Critical:** `file:` paths resolve relative to the backend package CWD +(`packages/backend/`), NOT the workspace root or the config file location. +A path like `../../workspaces/boost/catalog-info.yaml` resolves from +`packages/backend/`, not from the repo root. + +This catches people when entities don't appear in the catalog — the path is +correct relative to the repo root but wrong relative to where the backend runs. + +## Translation Module Entry Points + +Translation modules (`createFrontendModule` with `pluginId: 'app'`) are NOT +auto-discovered by `app.packages: all`. They need their own entry point: + +```tsx +// src/translations.ts +export { translationsModule as default } from './plugin'; +``` + +```json +// package.json exports +{ + "exports": { + ".": "./src/index.ts", + "./translations": "./src/translations.ts", + "./package.json": "./package.json" + } +} +``` + +Then either import explicitly in the app's features or ensure the translations +entry point is listed as a discoverable export. + +## Reference Workspaces + +Real dev app examples in rhdh-plugins: +- `workspaces/orchestrator/packages/app/` — full NFS app with sidebar, auth, catalog +- `workspaces/homepage/packages/app/` — simpler NFS app setup +- `workspaces/lightspeed/packages/app/` — NFS with AI-specific extensions diff --git a/skills/rhdh-coding/references/frontend-specs.md b/skills/rhdh-coding/references/frontend-specs.md new file mode 100644 index 0000000..3e0fafb --- /dev/null +++ b/skills/rhdh-coding/references/frontend-specs.md @@ -0,0 +1,86 @@ +# Frontend Spec Guidance + +When writing specs, PRDs, or OpenSpec proposals/designs for frontend features, +include these sections. Works with spec-start, OpenSpec, or any spec workflow. +Scale to complexity. + +## Components + +List every component the feature introduces or modifies. State what it is, +what it does, and its key interactions. + +```markdown +### FilterPanel +Sidebar with search input and category checkboxes. Filters the main data table. +- Search: debounced text input, filters by name +- Categories: checkbox group, multiple selection, filters immediately on change +- Reset: button that clears all filters + +### DataTable +Sortable, paginated table showing filtered results. +- Columns: name, status, last updated, actions +- Sort: click column header toggles asc/desc +- Pagination: 25 rows per page +- Row click navigates to detail page +- Empty state when no results match filters +``` + +Describe what the user sees and does, not implementation details. + +## Design Reference + +Point to where the design lives. + +```markdown +Figma: https://figma.com/file/abc123/feature-name +Frames: "Dashboard — Desktop", "Dashboard — Mobile" +``` + +With Figma MCP configured (`claude plugin install figma@claude-plugins-official`), +the agent reads the design directly — component structure, layout constraints, +spacing tokens, typography. No manual token tables or mockup exports needed. + +When no Figma exists: +- Reference existing UI: "Follow the layout of the existing Topology page" +- Compose from design system: "BUI Card grid, 3 columns on desktop, 1 on mobile" +- Attach mockups in the spec directory + +## Acceptance Criteria + +Concrete, testable statements per component. The definition of "done." + +```markdown +### FilterPanel +- Typing in search filters the table within 300ms (debounced) +- Selecting a category checkbox immediately filters visible rows +- Clicking Reset clears search input and unchecks all checkboxes +- Filter state persists in URL query params (shareable, survives refresh) + +### DataTable +- Clicking a column header sorts the table; clicking again reverses sort +- When filters return zero results: "No results match your filters" with Reset action +- Loading state shows skeleton rows while data loads +- Error state shows error message with Retry button +``` + +Specific and testable — not "should be responsive" or "handles errors gracefully." + +## Accessibility + +Only when the component has custom interaction patterns beyond standard HTML. + +```markdown +- Tab order: search → checkboxes → reset → table headers → table rows +- Filter changes announced: "Showing 12 of 45 results" via live region +- Sort state announced: "Sorted by name, ascending" on column activation +``` + +Skip for components that are pure composition of standard elements. + +## Scaling + +| Complexity | Components | Design | Acceptance | Accessibility | +|-----------|------------|--------|------------|---------------| +| Simple | 1-2 lines | Skip | 2-3 criteria | Skip | +| Medium | Full list | Figma link or reference | Full criteria | If custom interaction | +| Large | Full list | Figma + frames | Full criteria | Full section | diff --git a/skills/rhdh-coding/references/nfs.md b/skills/rhdh-coding/references/nfs.md new file mode 100644 index 0000000..a4d6eab --- /dev/null +++ b/skills/rhdh-coding/references/nfs.md @@ -0,0 +1,155 @@ +# NFS (New Frontend System) Coding Patterns + +## Alpha File Structure (`src/alpha.tsx`) + +```tsx +import { + ApiBlueprint, createApiFactory, createFrontendModule, createFrontendPlugin, + createRouteRef, createSubRouteRef, PageBlueprint, +} from '@backstage/frontend-plugin-api'; +import { TranslationBlueprint } from '@backstage/plugin-app-react'; +import { EntityCardBlueprint, EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha'; +``` + +## Route Refs + +```tsx +const rootRouteRef = createRouteRef(); // bulk-import alpha.tsx +const detailRouteRef = createSubRouteRef({ parent: rootRouteRef, path: '/detail/:id' }); +``` + +Legacy `createRouteRef({ id: 'xxx' })` from `@backstage/core-plugin-api` also works (orchestrator) but prefer NFS-native. + +## PageBlueprint + +```tsx +// bulk-import alpha.tsx +PageBlueprint.make({ + params: { + path: '/bulk-import', routeRef: rootRouteRef, noHeader: true, + loader: () => import('./components').then(({ Router }) => ), + }, +}); +``` + +**noHeader**: RHDH plugins commonly use `noHeader: true` when the plugin renders its own header with breadcrumbs, tabs, or permission controls. Omit it if you want the framework-provided `PluginHeader`. + +**title is required for sidebar nav.** If `PageBlueprint` doesn't have `title` set (either directly or inherited from `createFrontendPlugin`), the page won't appear in the sidebar navigation. Always set `title` and `icon` on the plugin or the page. + +## ApiBlueprint + +```tsx +// orchestrator alpha.tsx +ApiBlueprint.make({ + params: defineParams => defineParams( + createApiFactory({ + api: orchestratorApiRef, + deps: { discoveryApi: discoveryApiRef, identityApi: identityApiRef }, + factory: ({ discoveryApi, identityApi }) => + new OrchestratorClient({ discoveryApi, identityApi }), + }), + ), +}); +``` + +Multiple APIs in one plugin require a `name`: `ApiBlueprint.make({ name: 'npmBackendApi', params: ... })`. + +## EntityContentBlueprint and EntityCardBlueprint + +```tsx +// orchestrator alpha.tsx — entity tab +EntityContentBlueprint.make({ + name: 'workflows', + params: { + path: '/workflows', title: 'Workflows', + filter: (entity) => Boolean(entity.metadata?.annotations?.['orchestrator.io/workflows']), + loader: () => import('./components/CatalogTab').then(m => ), + }, +}); +// npm alpha.tsx (community-plugins) — entity card +EntityCardBlueprint.make({ + name: 'EntityNpmInfoCard', + params: { + filter: isNpmAvailable, + loader: () => import('./components/EntityNpmInfoCard').then(m => ), + }, +}); +``` + +## Nav items + +`NavItemBlueprint` was removed. Nav items are now auto-discovered from `PageBlueprint` extensions with `title`, `icon`, and `routeRef`. No separate blueprint needed. + +## Plugin and Module Exports + +Default export = plugin. TranslationBlueprint must go in a separate module with `pluginId: 'app'`: + +```tsx +export default createFrontendPlugin({ + pluginId: 'bulk-import', + extensions: [bulkImportApi, bulkImportPage], + routes: { root: rootRouteRef, tasks: importHistoryRouteRef }, +}); +export const translationsModule = createFrontendModule({ + pluginId: 'app', + extensions: [TranslationBlueprint.make({ + name: 'bulk-import-translations', params: { resource: bulkImportTranslations }, + })], +}); +``` + +**Translation modules are NOT auto-discovered** by `app.packages: all`. They +need a separate entry point — re-export as default from a dedicated file and +add as its own export in `package.json`. See `references/dev-app.md` for the +pattern. + +## Package Exports + +### New NFS-only plugins + +The NFS plugin is the root export (`.`). No `./alpha`, no `./legacy`, no +scalprum config, no `dist-scalprum/`. The app discovers and loads it via +`app.packages`. + +```json +{ "main": "src/index.ts", "types": "src/index.ts", + "exports": { ".": "./src/index.ts", "./package.json": "./package.json" }, + "backstage": { "role": "frontend-plugin", "pluginId": "my-plugin" } } +``` + +No scalprum section needed. No `export-dynamic-plugin` step. Package as a +standard npm package in an OCI image for deployment. + +### Migrated plugins (NFS default, legacy preserved) + +RHDH's current migration pattern makes NFS the root export (`.`) and moves the +old frontend system to `./legacy`: + +```json +{ "exports": { + ".": "./src/index.ts", + "./legacy": "./src/legacy.ts", + "./package.json": "./package.json" + }, + "typesVersions": { "*": { "legacy": ["src/legacy.ts"] } } } +``` + +- `src/index.ts` — NFS plugin (`createFrontendPlugin`, Blueprints) +- `src/legacy.ts` — old system (`createPlugin`, `createRoutableExtension`) with `@deprecated` tags + +Some older plugins still use the `./alpha` pattern (NFS at `./alpha`, legacy at +root). That pattern is being phased out — new migrations should put NFS at root. + +**Always check a plugin's `package.json` exports** before assuming where NFS +lives — it could be at `.`, `./alpha`, or a custom path. + +The `scalprum.exposedModules` entries some plugins still have are transition +baggage — NFS apps don't load through scalprum. Remove when legacy is dropped. + +## compatWrapper + +`compatWrapper` from `@backstage/core-compat-api` bridges legacy components into NFS. + +- **Needed**: plugin uses `@material-ui/*` (MUI v4) or legacy route refs (tech-radar uses `compatWrapper` + `convertLegacyRouteRef`). +- **Not needed**: plugin uses `@mui/*` (MUI v5) and NFS-native route refs (orchestrator, bulk-import skip it). +- **Not needed for new NFS-only plugins**: no legacy code to bridge. diff --git a/skills/rhdh-coding/references/plugin-types.md b/skills/rhdh-coding/references/plugin-types.md new file mode 100644 index 0000000..f843420 --- /dev/null +++ b/skills/rhdh-coding/references/plugin-types.md @@ -0,0 +1,79 @@ +# Plugin Type Decision Guide + +## Decision Table + +| I want to... | Type | Extension / Blueprint | +|--------------|------|----------------------| +| Add a standalone page with sidebar link | Page | `PageBlueprint` | +| Show a summary card on entity overview | Entity card | `EntityCardBlueprint` | +| Add a full tab to entity pages | Entity content | `EntityContentBlueprint` | +| Add sub-tabs within my page | Page (tabbed) | `PageBlueprint` + `SubPageBlueprint` | +| Create a custom template form field | Scaffolder field | `ScaffolderFieldBlueprint` | +| Provide custom branding/colors | Theme | `themes` wiring section | +| Add a REST API backend | Backend plugin | `createBackendPlugin` | +| Add a catalog processor or entity provider | Backend module | `createBackendModule` (pluginId: `catalog`) | +| Add a scaffolder action | Backend module | `createBackendModule` (pluginId: `scaffolder`) | +| Add an auth provider | Backend module | `createBackendModule` (pluginId: `auth`) | +| Add content to the RHDH app drawer | RHDH extension | `AppDrawerContentBlueprint` | +| Add a global header menu item | RHDH extension | `GlobalHeaderMenuItemBlueprint` | + +## Page Plugin + +Standalone feature with its own URL path. Examples: dashboard, admin panel, cost explorer. + +- Mount: `/my-plugin` (top-level route with optional sidebar entry) +- NFS: `PageBlueprint` with `path`, `title`, `routeRef`, `loader` +- Legacy: `createRoutableExtension` bound to `createRouteRef` +- Wiring: `dynamicRoutes` with optional `menuItem` +- Tabbed pages: use `SubPageBlueprint` for tabs within the page + +## Entity Card + +Summary widget on an entity overview page. Examples: build status, health score, link list. + +- Mount: `entity.page.overview/cards` (or other tab's `/cards`) +- NFS: `EntityCardBlueprint` with `filter` and `loader` +- Legacy: `createComponentExtension` +- Wiring: `mountPoints` with `config.if` for entity conditions +- Sizing: `config.layout.gridColumn` controls card width (`span 1`, `1 / -1` for full width) +- Entity conditions: `isKind('component')`, `isType('service')`, `hasAnnotation('key')` + +## Entity Tab / Content + +Full-page detail view on an entity page. Examples: CI pipeline view, API docs, topology. + +- Appears as a tab in the entity page header +- NFS: `EntityContentBlueprint` with `path`, `title`, `filter`, `loader` +- Legacy: `createRoutableExtension` mounted via `EntityLayout.Route` +- Wiring: `mountPoints` for content + `entityTabs` for tab definition +- Uses `useEntity()` for entity context + +## Backend Plugin + +Standalone backend with HTTP routes. Examples: REST API, proxy, data aggregator. + +- `createBackendPlugin` with `coreServices.httpRouter` +- Core services: `httpRouter`, `logger`, `rootConfig`, `httpAuth`, `database` +- Default export required from `src/index.ts` +- Package role: `backend-plugin` + +## Backend Module + +Extends an existing backend plugin via extension points. Examples: catalog processor, scaffolder action, auth provider. + +- `createBackendModule` with `pluginId` of the target plugin +- Package role: `backend-plugin-module` + +| Target | Extension point | Package | +|--------|----------------|---------| +| Catalog | `catalogProcessingExtensionPoint` | `@backstage/plugin-catalog-node` | +| Scaffolder | `scaffolderActionsExtensionPoint` | `@backstage/plugin-scaffolder-node` | +| Auth | `authProvidersExtensionPoint` | `@backstage/plugin-auth-node` | +| Permissions | `permissionPolicyExtensionPoint` | `@backstage/plugin-permission-node` | +| Search | `searchIndexRegistryExtensionPoint` | `@backstage/plugin-search-backend-node` | + +## Common Combinations + +- **Page + Entity card**: Page for detail, card on overview linking to it +- **Entity content + Entity card**: Tab for detail, card on overview +- **Frontend + Backend + Common**: Three-tier with shared types in `-common` package diff --git a/skills/rhdh-coding/references/rhdh.md b/skills/rhdh-coding/references/rhdh.md new file mode 100644 index 0000000..8239927 --- /dev/null +++ b/skills/rhdh-coding/references/rhdh.md @@ -0,0 +1,141 @@ +# RHDH-Specific Patterns + +Patterns that apply only to Red Hat Developer Hub, not upstream Backstage. + +## Dynamic Plugin Entry Points + +### Frontend +Named exports from `src/index.ts` become `importName` values in wiring YAML: +```typescript +export { MyPage } from './plugin'; // importName: "MyPage" +export { MyCard } from './plugin'; // importName: "MyCard" +``` + +### Backend +Default export is required: +```typescript +// src/index.ts +export { default } from './plugin'; +``` +Missing default export = plugin won't load. This is the #1 backend plugin issue. + +## Scalprum Name + +Derived from package name: `@red-hat-developer-hub/backstage-plugin-foo` → +`red-hat-developer-hub.backstage-plugin-foo` + +Override via `scalprum.name` in `package.json`. Must match the key under +`dynamicPlugins.frontend.` in `dynamic-plugins.yaml`. + +## MUI v5 Class Name Generator + +Required for any plugin using `@mui/material` in RHDH dynamic plugin bundles: +```typescript +// src/index.ts +import { unstable_ClassNameGenerator as ClassNameGenerator } from '@mui/material/className'; +ClassNameGenerator.configure(name => name.startsWith('v5-') ? name : `v5-${name}`); +``` + +## Auth + +Use `fetchApi` for all HTTP requests — auth headers are included automatically. +Access user identity via `identityApi.getCredentials()`. Don't implement custom +auth flows in plugins. + +## Theming + +- Dev harness: `getAllThemes()` from `@red-hat-developer-hub/backstage-plugin-theme` +- Production: RHDH app shell provides theming automatically +- Custom themes: `createUnifiedTheme` from `@backstage/theme`, register via `themes` wiring + +## i18n / Translations + +```tsx +import { createFrontendModule } from '@backstage/frontend-plugin-api'; +import { TranslationBlueprint } from '@backstage/plugin-app-react'; + +const translationModule = createFrontendModule({ + pluginId: 'app', // targets the app, NOT your plugin + modules: { + translations: TranslationBlueprint.make({ + namespace: 'plugin.my-plugin', + resources: [{ locale: 'en', messages: enMessages }], + }), + }, +}); +``` + +## RHDH-Only Blueprints + +| Blueprint | Package | Purpose | +|-----------|---------|---------| +| `AppDrawerContentBlueprint` | `@red-hat-developer-hub/backstage-plugin-dynamic-plugins-react` | App drawer panels | +| `GlobalHeaderMenuItemBlueprint` | `@red-hat-developer-hub/backstage-plugin-global-header` | Global header menu items | + +These are NOT upstream Backstage. If the plugin must work outside RHDH, gate +these behind a separate package or conditional check. + +## Backend Module Patterns + +### Catalog processor +```typescript +import { catalogProcessingExtensionPoint } from '@backstage/plugin-catalog-node'; + +export default createBackendModule({ + pluginId: 'catalog', + moduleId: 'my-processor', + register(reg) { + reg.registerInit({ + deps: { catalog: catalogProcessingExtensionPoint }, + async init({ catalog }) { + catalog.addProcessor(new MyProcessor()); + }, + }); + }, +}); +``` + +### Scaffolder action +```typescript +import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node'; + +export default createBackendModule({ + pluginId: 'scaffolder', + moduleId: 'my-action', + register(reg) { + reg.registerInit({ + deps: { scaffolder: scaffolderActionsExtensionPoint }, + async init({ scaffolder }) { + scaffolder.addActions(createMyAction()); + }, + }); + }, +}); +``` + +## Common Package Pattern + +When a feature spans frontend and backend, share types via `-common`: +``` +@scope/backstage-plugin-foo # frontend +@scope/backstage-plugin-foo-backend # backend +@scope/backstage-plugin-foo-common # shared types, API ref, constants +``` + +Common package role: `common-library`. Export types with `/** @public */` JSDoc. +Use `import type` for type-only imports to avoid bundling common into backend. + +## Namespace Conventions + +| Scope | Pattern | +|-------|---------| +| RHDH plugins | `@red-hat-developer-hub/backstage-plugin-` | +| Community plugins | `@backstage-community/plugin-` | +| Custom plugins | `@/backstage-plugin-` | + +Plugin ID: kebab-case, no `backstage-plugin-` prefix. Must match `backstage.pluginId` +in `package.json`. + +## Version Compatibility + +Consult `../rhdh/references/versions.md` for the full matrix. diff --git a/skills/rhdh-coding/references/testing.md b/skills/rhdh-coding/references/testing.md new file mode 100644 index 0000000..6fed102 --- /dev/null +++ b/skills/rhdh-coding/references/testing.md @@ -0,0 +1,122 @@ +# Backstage Testing Patterns + +## TestApiProvider + renderInTestApp + +The dominant pattern. `renderInTestApp` provides routing/theme; `TestApiProvider` supplies mock APIs. + +```tsx +// badges plugin (community-plugins) — EntityBadgesDialog.test.tsx +import { renderInTestApp, TestApiProvider } from '@backstage/test-utils'; + +const mockApi: jest.Mocked = { + getEntityBadgeSpecs: jest.fn().mockResolvedValue([ + { id: 'testbadge', badge: { label: 'test', message: 'badge' } }, + ]), +}; +const rendered = await renderInTestApp( + + + + + , +); +await expect(rendered.findByText('test: badge')).resolves.toBeInTheDocument(); +``` + +## mountedRoutes for Routable Extensions + +When a component uses `useRouteRef`, pass `mountedRoutes` as second arg to `renderInTestApp`: + +```tsx +// x2a plugin (rhdh-plugins) — Dashboard.test.tsx +await renderInTestApp(, + { mountedRoutes: { '/x2a': rootRouteRef } }); +``` + +## Mocking API Clients (mock the interface, not HTTP) + +```tsx +// x2a plugin — Dashboard.test.tsx +import { mockApis } from '@backstage/test-utils'; +const discoveryApiMock = mockApis.discovery({ baseUrl: 'http://localhost:1234' }); +const permissionApiMock = { + authorize: jest.fn().mockResolvedValue({ result: AuthorizeResult.ALLOW }), +}; +``` + +## Entity Context Mocking + +**EntityProvider wrapper** (badges plugin): `` + +**jest.mock useEntity** (acs plugin, community-plugins): + +```tsx +jest.mock('@backstage/plugin-catalog-react', () => ({ + ...jest.requireActual('@backstage/plugin-catalog-react'), + useEntity: jest.fn().mockReturnValue({ + metadata: { annotations: { 'acs/deployment-name': 'test-deployment' } }, + }), +})); +``` + +Always spread `jest.requireActual()` to preserve other exports. + +## Testing Async Components + +Use `waitFor` or `findByText`/`findByRole` for data-loading components: + +```tsx +await waitFor(() => expect(screen.getByText('Loading...')).toBeInTheDocument()); // acs plugin +await expect(rendered.findByText('test: badge')).resolves.toBeInTheDocument(); // badges plugin +``` + +## Testing Custom Hooks + +```tsx +// adoption-insights plugin — useActiveUsers.test.tsx +import { renderHook, waitFor } from '@testing-library/react'; + +const mockApi = { getActiveUsers: jest.fn() }; +(useApi as jest.Mock).mockReturnValue(mockApi); +mockApi.getActiveUsers.mockResolvedValueOnce({ data: [] }); + +const { result } = renderHook(() => useActiveUsers()); +expect(result.current.loading).toBe(true); +await waitFor(() => { + expect(result.current.loading).toBe(false); +}); +``` + +## MSW for HTTP Tests (real API client classes) + +```tsx +// x2a plugin — Dashboard.test.tsx +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '@backstage/test-utils'; + +const server = setupServer(); +registerMswTestHooks(server); // handles listen/close/reset +beforeEach(() => { + server.use(rest.get('/*', (_, res, ctx) => res(ctx.status(200), ctx.json({})))); +}); +``` + +## Permission Testing (SWR cache reset) + +```tsx +// playlist plugin (community-plugins) + new Map() }}> + + + + +``` + +## Common Gotchas + +- **renderInTestApp is async** -- always `await` it. Forgetting causes flaky tests. +- **No snapshots with MUI** -- non-deterministic class names. Unused except for SVG icons. +- **jest.mock hoisting** -- use `jest.requireActual()` inside mock factories for partial mocks. +- **Translation mocking** -- rhdh-plugins use `test-utils/mockTranslations.ts`. Mock `useTranslation`. +- **Accessibility testing** -- neither repo currently uses jest-axe or axe-core in unit tests. The official `plugin-analytics-instrumentation` skill notes that BUI components have built-in a11y. For a11y enforcement, consider e2e axe-core via Playwright (the rhdh-plugins homepage workspace has an example in `e2e-tests/utils/accessibility.ts`). diff --git a/skills/rhdh-coding/scripts/detect-rhdh-context.py b/skills/rhdh-coding/scripts/detect-rhdh-context.py new file mode 100644 index 0000000..79e75fa --- /dev/null +++ b/skills/rhdh-coding/scripts/detect-rhdh-context.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Detect RHDH/Backstage plugin context for the rhdh-coding skill. + +Reads package.json, plugin definition files, and route refs to identify the +plugin's architecture, frontend system (legacy vs NFS), existing extensions, +API refs, mount points, and MUI version. +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def detect_backstage_role(pkg: dict) -> str: + return pkg.get("backstage", {}).get("role", "unknown") + + +def detect_plugin_id(pkg: dict) -> str: + return pkg.get("backstage", {}).get("pluginId", "") + + +def detect_frontend_system(src: Path) -> str: + if (src / "alpha.tsx").exists() or (src / "alpha.ts").exists(): + plugin_ts = src / "plugin.ts" + plugin_tsx = src / "plugin.tsx" + if plugin_ts.exists() or plugin_tsx.exists(): + return "dual" + return "nfs" + + if (src / "plugin.ts").exists() or (src / "plugin.tsx").exists(): + return "legacy" + + return "unknown" + + +def detect_mui_version(deps: dict) -> str: + if "@mui/material" in deps: + return "v5" + if "@material-ui/core" in deps: + return "v4" + return "none" + + +def detect_extensions(src: Path) -> list: + extensions = [] + for f in [src / "plugin.ts", src / "plugin.tsx"]: + if not f.exists(): + continue + content = f.read_text(errors="replace") + for match in re.finditer( + r"createRoutableExtension\(\s*\{[^}]*name:\s*['\"](\w+)['\"]", content + ): + extensions.append({"name": match.group(1), "type": "routable"}) + for match in re.finditer( + r"createComponentExtension\(\s*\{[^}]*name:\s*['\"](\w+)['\"]", content + ): + extensions.append({"name": match.group(1), "type": "component"}) + return extensions + + +def detect_nfs_blueprints(src: Path) -> list: + blueprints = [] + for f in [src / "alpha.tsx", src / "alpha.ts"]: + if not f.exists(): + continue + content = f.read_text(errors="replace") + for bp_type in [ + "PageBlueprint", + "EntityCardBlueprint", + "EntityContentBlueprint", + "ApiBlueprint", + "SubPageBlueprint", + ]: + if bp_type in content: + blueprints.append(bp_type) + return blueprints + + +def detect_route_refs(src: Path) -> list: + refs = [] + for f in [src / "routes.ts", src / "routes.tsx"]: + if not f.exists(): + continue + content = f.read_text(errors="replace") + for match in re.finditer(r"createRouteRef\(\s*\{[^}]*id:\s*['\"]([^'\"]+)['\"]", content): + refs.append({"id": match.group(1), "type": "route"}) + for match in re.finditer( + r"createExternalRouteRef\(\s*\{[^}]*id:\s*['\"]([^'\"]+)['\"]", content + ): + refs.append({"id": match.group(1), "type": "external"}) + return refs + + +def detect_api_refs(src: Path) -> list: + refs = [] + for f in src.rglob("*.ts"): + if "__tests__" in str(f) or ".test." in f.name or ".spec." in f.name: + continue + try: + content = f.read_text(errors="replace") + except OSError: + continue + for match in re.finditer( + r"createApiRef<[^>]*>\(\s*\{[^}]*id:\s*['\"]([^'\"]+)['\"]", content + ): + refs.append(match.group(1)) + return list(set(refs)) + + +def detect_dynamic_plugin(pkg: dict, project_path: Path) -> dict: + result = {"isDynamic": False} + + scalprum = pkg.get("scalprum", {}) + if scalprum: + result["isDynamic"] = True + result["scalprumName"] = scalprum.get("name", "") + + if (project_path / "dist-scalprum").exists(): + result["isDynamic"] = True + result["hasDistScalprum"] = True + + return result + + +def main(): + parser = argparse.ArgumentParser( + description="Detect RHDH/Backstage plugin context for the rhdh-coding skill.", + ) + parser.add_argument( + "--path", + type=str, + default=".", + help="Path to the plugin root (default: current directory)", + ) + args = parser.parse_args() + + project_path = Path(args.path).resolve() + pkg_path = project_path / "package.json" + + if not pkg_path.exists(): + result = {"error": f"No package.json found at {project_path}"} + json.dump(result, sys.stdout, indent=2) + sys.exit(1) + + with open(pkg_path) as f: + pkg = json.load(f) + + deps = {**pkg.get("dependencies", {}), **pkg.get("devDependencies", {})} + src = project_path / "src" + + result = { + "projectPath": str(project_path), + "packageName": pkg.get("name", "unknown"), + "backstageRole": detect_backstage_role(pkg), + "pluginId": detect_plugin_id(pkg), + "frontendSystem": detect_frontend_system(src), + "muiVersion": detect_mui_version(deps), + "extensions": detect_extensions(src), + "nfsBlueprints": detect_nfs_blueprints(src), + "routeRefs": detect_route_refs(src), + "apiRefs": detect_api_refs(src), + "dynamicPlugin": detect_dynamic_plugin(pkg, project_path), + "pluginPackages": pkg.get("backstage", {}).get("pluginPackages", []), + } + + if sys.stdout.isatty(): + json.dump(result, sys.stdout, indent=2) + else: + json.dump(result, sys.stdout) + + print() + + +if __name__ == "__main__": + main()