chore: migrate cursor rules to portable agent skills + mantine MCP guidance

* chore: migrate cursor rules to portable agent skills and add mantine MCP guidance

Replace Cursor-only .cursor/rules/*.mdc with Agent Skills open-standard
SKILL.md files in .agents/skills/ (canonical), mirrored to .cursor/skills/
and .claude/skills/ so they work across Claude Code, Cursor, Codex, and
OpenCode. Add mantine MCP server guidance to AGENTS.md and un-ignore
.agents/skills/ in .gitignore.

* refactor: single-source agent skills at .agents/skills

Cursor, Codex, and OpenCode all read .agents/skills/ natively, so the
.cursor/skills/ mirror was redundant. Claude Code reads .claude/skills/,
which is now a symlink back to .agents/skills/ so the content lives once.
Deletes .cursor/skills/ and converts .claude/skills/ to a symlink.

* chore: ship mantine MCP server via project-scoped .mcp.json

Declares the keyless @mantine/mcp-server in .mcp.json (Claude Code
project-scope standard, committable) and documents MCP server setup and
the mantine entry in AGENTS.md.

* chore: address coderabbit security-review and markdown lint findings

Add explicit security-review guidance before exposing MCP tools and label
plain-text code fences to satisfy markdownlint MD040.
This commit is contained in:
Thomas authored and GitHub committed 2026-08-16 16:27:51 +02:00
1 parent ddbf05524b
commit 05ba57c2fa
9 files changed
+234 -155

No files matched your search

@@ -1,6 +1,6 @@
--- ---
description: Comprehensive Homarr codebase architecture and conventions reference name: codebase-context
alwaysApply: true description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
--- ---
# Homarr Codebase Context # Homarr Codebase Context
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
## Repository Structure ## Repository Structure
``` ```text
homarr/ homarr/
├── apps/ ├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000) │ ├── nextjs/ # Main Next.js application (port 3000)
@@ -66,8 +66,6 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami) │ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types │ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context │ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli) │ └── cli/ # Node CLI for ops (brocli)
├── tooling/ ├── tooling/
│ ├── typescript/ # Base tsconfig │ ├── typescript/ # Base tsconfig
@@ -79,11 +77,11 @@ homarr/
## Three Runtime Services ## Three Runtime Services
| Service | Port | Role | | Service | Port | Role |
|---------|------|------| | --------- | ---- | -------------------------------------------- |
| Next.js | 3000 | Main web app (SSR + API routes) | | Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` | | WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify | | Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header. In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
### Multi-Database Support ### Multi-Database Support
Drizzle schemas exist in three parallel implementations: Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts` - `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts` - `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts` - `packages/db/schema/postgresql.ts`
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System ### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget: Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })` - Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options - Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic` - Loads dynamically via `next/dynamic`
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
### Cron Job System ### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job: Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression - Uses `createCronJob` with a cron expression
- Publishes results to Redis channels - Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions - Widget subscriptions consume those channels via tRPC subscriptions
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
## Environment Variables ## Environment Variables
Key env vars (from `.env.example`): Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres` - `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string - `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret - `AUTH_SECRET`: NextAuth secret
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
- Drag-and-drop via `@dnd-kit/*` - Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`) - `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom` - `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
## Documentation (`apps/docs`)
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
- Run with `pnpm dev:docs` from root
- Build with `pnpm turbo build --filter=@homarr/docs`
- Uses Tailwind CSS 3 for custom pages (not Mantine)
- Imports `@homarr/definitions` for type-safe integration/widget metadata
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
- Strict link checking: broken links/anchors cause build failure
@@ -1,3 +1,8 @@
---
name: datatable-migration
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
---
# Migrating Tables from mantine-react-table to mantine-datatable # Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables. This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
Current `mantine-react-table` consumers that could be migrated: Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):** **Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table - `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions - `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):** **Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx` - `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx` - `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx` - `tools/api/components/api-keys.tsx`
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
- `tools/kubernetes/` — 8 Kubernetes resource tables - `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:** **Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx` - `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:** **Shared hook to remove once all migrated:**
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
--- - `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
## 1. Core Migration: mantine-react-table → mantine-datatable ## 1. Core Migration: mantine-react-table → mantine-datatable
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
``` ```
Key differences: Key differences:
- `accessorKey` → `accessor` - `accessorKey` → `accessor`
- `header` → `title` - `header` → `title`
- `size` → `width` - `size` → `width`
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} /> <DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
``` ```
---
## 2. Making Tables Transparent (Widget Context) ## 2. Making Tables Transparent (Widget Context)
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque. Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
@@ -144,13 +150,21 @@ Create a CSS file alongside the component:
/* Hover: translucent instead of opaque */ /* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover { .my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important; background-color: color-mix(
in srgb,
var(--mantine-color-default-hover) 40%,
transparent
) !important;
} }
/* Headers: frosted glass effect */ /* Headers: frosted glass effect */
.my-table th { .my-table th {
white-space: nowrap; white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important; background-color: color-mix(
in srgb,
var(--mantine-color-body) 60%,
transparent
) !important;
backdrop-filter: blur(8px); backdrop-filter: blur(8px);
} }
@@ -172,14 +186,16 @@ Create a CSS file alongside the component:
/* Row expansion area */ /* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell { .my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important; background-color: color-mix(
in srgb,
var(--mantine-color-body) 40%,
transparent
) !important;
} }
``` ```
Import it: `import "./styles.css";` and apply via `className="my-table"`. Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility ## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop. Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
@@ -195,11 +211,38 @@ interface SizeConfig {
} }
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [ const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } }, {
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } }, maxWidth: 300,
config: {
fontSize: "xs",
iconSize: 12,
cellPadding: 2,
showSpeedColumns: false,
showTimeColumn: false,
showStateColumn: false,
},
},
{
maxWidth: 500,
config: {
fontSize: "xs",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: false,
showTimeColumn: true,
showStateColumn: false,
},
},
]; ];
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true }; const DEFAULT_SIZE_CONFIG: SizeConfig = {
fontSize: "sm",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: true,
showTimeColumn: true,
showStateColumn: true,
};
function getSizeConfig(width: number): SizeConfig { function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) { for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
@@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]);
Then use a visibility check lookup table for columns: Then use a visibility check lookup table for columns:
```typescript ```typescript
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = { const columnVisibilityChecks: Partial<
Record<string, (ctx: ColumnContext) => boolean>
> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns, downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn, time: (ctx) => ctx.size.showTimeColumn,
@@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
} }
``` ```
---
## 4. Column Persistence (Drag/Resize → Server) ## 4. Column Persistence (Drag/Resize → Server)
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices. `useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
@@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails:
"widget.mywidget.option.columnWidths.label": "Column widths" "widget.mywidget.option.columnWidths.label": "Column widths"
``` ```
### Old-import mappings
Add to `packages/old-import/src/widgets/options.ts`:
```typescript
columnOrder: () => undefined,
columnWidths: () => undefined,
```
### Dynamic storeKey ### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding: Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
@@ -285,8 +319,13 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back: On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript ```typescript
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } = const {
useDataTableColumns<MyRow>({ key: storeKey, columns }); effectiveColumns,
columnsOrder,
columnsWidth,
setColumnsOrder,
setMultipleColumnWidths,
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey); const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false); const hydrated = useRef(false);
@@ -300,11 +339,22 @@ useEffect(() => {
if (savedOrder.length > 0) setColumnsOrder(savedOrder); if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) { if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths( setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })), Object.entries(savedWidths).map(([accessor, w]) => ({
accessor,
width: w,
})),
); );
} }
requestAnimationFrame(() => { hydrated.current = true; }); requestAnimationFrame(() => {
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]); hydrated.current = true;
});
}, [
storeKey,
savedOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
]);
``` ```
### Persist localStorage → server ### Persist localStorage → server
@@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth);
useEffect(() => { useEffect(() => {
if (!hydrated.current) return; if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current); const orderChanged =
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current); JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged =
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder; prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth; prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return; if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) }); if (orderChanged)
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) { if (widthsChanged) {
const widthMap: Record<string, number> = {}; const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) { for (const entry of columnsWidth) {
const key = Object.keys(entry)[0]; const key = Object.keys(entry)[0];
if (!key) continue; if (!key) continue;
const w = entry[key as keyof typeof entry]; const v = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w; if (typeof v === "number") widthMap[key] = v;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10); else if (typeof v === "string" && v.endsWith("px"))
widthMap[key] = parseInt(v, 10);
} }
persistOption({ columnWidths: JSON.stringify(widthMap) }); persistOption({ columnWidths: JSON.stringify(widthMap) });
} }
@@ -341,8 +395,6 @@ useEffect(() => {
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation). The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click) ## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly: Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
@@ -374,15 +426,41 @@ const handleContextMenu = useCallback(
The menu component uses `Portal` for correct z-index: The menu component uses `Portal` for correct z-index:
```tsx ```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) { function RowContextMenu({
state,
onClose,
}: {
state: ContextMenuState;
onClose: () => void;
}) {
return ( return (
<Portal> <Portal>
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}> <Menu
opened
onClose={onClose}
closeOnItemClick={false}
position="right-start"
offset={0}
>
<Menu.Target> <Menu.Target>
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} /> <Box
style={{
position: "fixed",
left: state.x,
top: state.y,
width: 0,
height: 0,
}}
/>
</Menu.Target> </Menu.Target>
<Menu.Dropdown> <Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item> <Menu.Item
onClick={() => {
/* action */ onClose();
}}
>
Action
</Menu.Item>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</Portal> </Portal>
@@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`. **Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
---
## 6. Row Expansion ## 6. Row Expansion
```typescript ```typescript
@@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work. **Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes ## 7. Common Pitfalls & Fixes
### Status indicator dots ### Status indicator dots
@@ -475,8 +549,11 @@ Always wrap in try/catch:
```typescript ```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T { function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback; if (!value) return fallback;
try { return JSON.parse(value) as T; } try {
catch { return fallback; } return JSON.parse(value) as T;
} catch {
return fallback;
}
} }
``` ```
@@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it:
```typescript ```typescript
useEffect(() => { useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection }); setSortStatus({
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}, [options.defaultSort, defaultSortDirection]); }, [options.defaultSort, defaultSortDirection]);
``` ```
---
## 8. Accessibility ## 8. Accessibility
- All `ActionIcon` components need `aria-label` - All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}` - Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize - `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling ## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification` - Every mutation needs an `onError` callback with `showErrorNotification`
@@ -518,12 +594,14 @@ useEffect(() => {
```typescript ```typescript
const mutationOptions = { const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(), onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }), onError: () =>
showErrorNotification({
title: t("errors.actionFailed"),
message: t("errors.actionFailedMessage"),
}),
}; };
``` ```
---
## 10. i18n ## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n` - No hardcoded strings. Every user-visible string goes through `useScopedI18n`
@@ -534,8 +612,6 @@ const mutationOptions = {
- Errors: `t("errors.{errorKey}")` - Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys - Hidden options still need `.label` translation keys
---
## 11. Edit Mode ## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop: Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
@@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} /> <DataTable style={{ pointerEvents: tablePointerEvents }} />
``` ```
---
## 12. DataTable Props Reference (Commonly Used) ## 12. DataTable Props Reference (Commonly Used)
```typescript ```typescript
@@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
/> />
``` ```
---
## 13. Layout Stability ## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic - Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
@@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length - Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names - `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration ## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`) 1. Replace imports (`mantine-react-table` → `mantine-datatable`)
@@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
4. Add CSS file for transparent backgrounds (if widget context) 4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context) 5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed) 6. Add hidden options + hydration logic (if column persistence needed)
7. Add old-import mappings for new options 7. Add translation keys (including hidden option labels)
8. Add translation keys (including hidden option labels) 8. Add context menu if row actions exist
9. Add context menu if row actions exist 9. Add row expansion if detail view exists
10. Add row expansion if detail view exists 10. Add error handling on all mutations
11. Add error handling on all mutations 11. Add accessibility attributes
12. Add accessibility attributes 12. Test at multiple widget widths
13. Test at multiple widget widths 13. Update docs in `apps/docs/docs/widgets/` if applicable
14. Update docs in `apps/docs/docs/widgets/` if applicable 14. Run `pnpm turbo typecheck`
15. Run `pnpm turbo typecheck` — catches old-import mismatches 15. Run translation spec — catches missing keys
16. Run translation spec — catches missing keys
@@ -0,0 +1,44 @@
---
name: documentation-sync
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
---
# Documentation Sync
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
| ---------------------- | ----------------------------------------------------------- |
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
@@ -1,13 +1,14 @@
--- ---
description: How to expose tRPC procedures as MCP tools for AI clients name: mcp-integration
globs: packages/api/src/router/**/*.ts description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
alwaysApply: false
--- ---
# MCP Integration Guide # MCP Integration
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr. Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
## Adding MCP to a procedure ## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain: Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
// Read-only tool (query) // Read-only tool (query)
myProcedure: protectedProcedure myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } }) .meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) })) .input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => { ... }) .query(async ({ ctx, input }) => { ... })
// Write tool (mutation) // Write tool (mutation)
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr. The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:** **Must include:**
- What the tool returns or does - What the tool returns or does
- Which integrations/services it works with (if applicable) - Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId") - How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious) - Permission requirements (if non-obvious)
**Good:** **Good:**
```
```text
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all" "Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
``` ```
**Bad:** **Bad:**
```
```text
"Get calendar events" "Get calendar events"
``` ```
**For tools that return permission fields**, explain what they mean: **For tools that return permission fields**, explain what they mean:
```
```text
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error" "List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
``` ```
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
## What to expose vs. what to skip ## What to expose vs. what to skip
**Expose:** **Expose:**
- Data queries (list, search, get by ID) - Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve) - Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries) - Status checks (health, stats, summaries)
**Skip:** **Skip:**
- Subscriptions (WebSocket-only, not supported by MCP) - Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps) - Internal procedures (session management, onboarding steps)
- File upload/download procedures - File upload/download procedures
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-50
View File
@@ -1,50 +0,0 @@
---
description: Keep documentation in sync with code changes
globs:
- packages/definitions/src/**
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/**
- apps/nextjs/src/**
- apps/docs/**
---
# Documentation Sync Rule
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
|---|---|
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
+2 -1
View File
@@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png
# personal agent config # personal agent config
.opencode .opencode
.agents/ .agents/*
!.agents/skills/
skills-lock.json skills-lock.json
# docusaurus # docusaurus
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server"]
}
}
}
+10
View File
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint) - Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier) - Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app - UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
## MCP servers
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
## Agent Skills
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
- Icons: @tabler/icons-react - Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types - Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images. - Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.