diff --git a/.changeset/calm-component-text-sizes.md b/.changeset/calm-component-text-sizes.md new file mode 100644 index 0000000000..d5e66b0ab0 --- /dev/null +++ b/.changeset/calm-component-text-sizes.md @@ -0,0 +1,5 @@ +--- +'@primer/brand-mcp': patch +--- + +Updated `primer_brand_page_design` tool to recommend component text defaults and `primer_brand_review` to warn about unnecessary `size` props. diff --git a/.changeset/connect-gridlines-to-page-edges.md b/.changeset/connect-gridlines-to-page-edges.md new file mode 100644 index 0000000000..98b53fb364 --- /dev/null +++ b/.changeset/connect-gridlines-to-page-edges.md @@ -0,0 +1,8 @@ +--- +'@primer/brand-mcp': patch +--- + +Updated MCP gridline tools to prevent disconnected, centered border boxes: + +- Updated `primer_brand_page_design` to prefer built-in gridline APIs and scope custom page-width frames to grouped Cards and Pillars. +- Updated `primer_brand_examples` to preserve canonical Card/Pillar companion CSS and surface the focused Statistic gridline example. diff --git a/.changeset/remove-card-skew-effect.md b/.changeset/remove-card-skew-effect.md new file mode 100644 index 0000000000..1b4b20dd4d --- /dev/null +++ b/.changeset/remove-card-skew-effect.md @@ -0,0 +1,5 @@ +--- +'@primer/react-brand': minor +--- + +⚠️ `CardSkewEffect` has been removed from `@primer/react-brand`. The effect has been retired, and no replacement will be issued. diff --git a/.changeset/route-agents-to-primer-brand.md b/.changeset/route-agents-to-primer-brand.md new file mode 100644 index 0000000000..2cb8192250 --- /dev/null +++ b/.changeset/route-agents-to-primer-brand.md @@ -0,0 +1,9 @@ +--- +'@primer/brand-mcp': minor +--- + +Updated `primer_brand_setup` to create or update `AGENTS.md` beside a `package.json` that declares `@primer/react-brand`. + +- Preserves existing instructions and appends/replaces only the Primer Brand instructions. +- Requires `projectDir` when several workspace packages declare Primer Brand. +- Routes future agents to the MCP tools and version-matched local documentation. diff --git a/apps/next-docs/content/components/Card/react.mdx b/apps/next-docs/content/components/Card/react.mdx index 79e05f61c5..aa33ea186f 100644 --- a/apps/next-docs/content/components/Card/react.mdx +++ b/apps/next-docs/content/components/Card/react.mdx @@ -169,37 +169,45 @@ When an image should stretch to fill the card width, set `padding="none"` on `Ca ```jsx live - {[ - { - heading: 'Build with GitHub Copilot', - description: 'Write, review, and understand code with AI-powered assistance.', - image: '/images/placeholder-1.png', - }, - { - heading: 'Automate your workflow', - description: 'Keep projects moving with tools that fit the way your team works.', - image: '/images/placeholder-2.png', - }, - { - heading: 'Ship secure software', - description: 'Find and fix vulnerabilities throughout the development lifecycle.', - image: '/images/placeholder-3.png', - }, - ].map(card => ( - - - {card.heading} - {card.description} - - - - ))} + + + Build with GitHub Copilot + Write, review, and understand code with AI-powered assistance. + + + + + + Automate your workflow + Keep projects moving with tools that fit the way your team works. + + + + + + Ship secure software + Find and fix vulnerabilities throughout the development lifecycle. + + + ``` diff --git a/apps/next-docs/content/introduction/mcp.mdx b/apps/next-docs/content/introduction/mcp.mdx index d2dd28fde1..436104ba0d 100644 --- a/apps/next-docs/content/introduction/mcp.mdx +++ b/apps/next-docs/content/introduction/mcp.mdx @@ -17,7 +17,7 @@ The server exposes a set of tools that AI agents can call: | Tool | What it does | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `primer_brand_setup` | Configures Primer Brand first-time. Includes `ThemeProvider`, Mona Sans and RSC-directive configuration among other things | +| `primer_brand_setup` | Configures Primer Brand first-time and maintains a Primer Brand pointer beside the declaring `package.json`. | | `primer_brand_page_design` | Page-design conventions (header/footer, hero media, gridline aesthetic, card grids, labels) and the current-brand reference templates to start from. | | `primer_brand_component` | Get a component API information like props, allowed values and named sub-components. | | `primer_brand_examples` | Reference examples taken from our Storybook. | @@ -42,6 +42,8 @@ Add the server to your workspace's `.vscode/mcp.json` (or your user `mcp.json`): } ``` +When an agent calls `primer_brand_setup`, the tool updates `AGENTS.md` only in a package whose `package.json` declares `@primer/react-brand`. Existing instructions are preserved. If several workspace packages declare the dependency, call setup with `projectDir` set to the intended workspace-relative package folder. The pointer routes future agents to the MCP tools and version-matched docs bundled with the installed package. + ## Set up in other clients For GitHub Copilot CLI and other `stdio`-enabled MCP clients, run the server directly: diff --git a/packages/mcp/README.md b/packages/mcp/README.md index b53ce49746..8e8652a535 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -8,7 +8,7 @@ It is a version-aware, `stdio` (local) server which reads the docs and metadata | Tool | What it does | | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | -| `primer_brand_setup` | Configures Primer Brand first-time. Includes `ThemeProvider`, Mona Sans and RSC-directive configuration among other things | +| `primer_brand_setup` | Configures Primer Brand first-time and maintains a Primer Brand pointer beside the declaring `package.json`. | | `primer_brand_page_design` | Page-design conventions (header/footer, hero media, gridline aesthetic, card grids, labels) and the current-brand reference templates to start from. | | `primer_brand_component` | Get a component API information like props, allowed values and named sub-components. | | `primer_brand_examples` | Reference examples taken from our Storybook. | @@ -35,6 +35,8 @@ Requires Node.js >= 24. } ``` +When an agent calls `primer_brand_setup`, the tool updates `AGENTS.md` only in a package whose `package.json` declares `@primer/react-brand`. Existing instructions are preserved. If several workspace packages declare the dependency, call setup with `projectDir` set to the intended workspace-relative package folder. The pointer routes future agents to the MCP tools and version-matched docs bundled with the installed package. + ### Copilot CLI / other stdio clients Run the server with `npx @primer/brand-mcp@latest`. diff --git a/packages/mcp/content/page-design.md b/packages/mcp/content/page-design.md index 8c1448e700..e32255281c 100644 --- a/packages/mcp/content/page-design.md +++ b/packages/mcp/content/page-design.md @@ -19,7 +19,9 @@ Pull adaptable component snippets with `primer_brand_examples`. `@primer/react-brand` spans multiple generations of GitHub's design language, and many components expose that choice as a variant or prop. **Gridline** is the current brand direction. It's a connected, ruled-line aesthetic, as seen throughout the Flexsuite templates. Bias toward it. - **Do** — when a component offers a gridline-family option, choose it over the older or default look. "Expressive" gridline options are a more pronounced sub-variant of the same aesthetic, not a competing style; reach for them where a section wants extra emphasis. +- **Do** — use a component's built-in `gridline` variant, gridline-family variant, or `hasGridLines` prop when available. - **Don't** — settle for a component's plainest or legacy variant, or borrow section layouts from an older brand generation, when a gridline equivalent exists. +- **Don't** — recreate built-in gridlines with custom `Box` wrappers, border props, or CSS. The gridline look is crucial and literal, not just a set of variants. Gridlines are thin ruled lines that frame the content column and separate sections (see "Contain content within the grid" section below). @@ -36,17 +38,22 @@ Every full page should be framed top and bottom, unless the user has requested a ### Contain content within the grid -Body content must sit in one centered, max-width grid framed by the brand's gridlines — it should rarely stretch edge-to-edge. Treat gridlines as one continuous page structure: every line endpoint must reach the viewport or shared page-grid boundary, or intersect another gridline. Never let a border stop in empty space. +Body content must sit in one centered, max-width grid framed by the brand's gridlines — it should rarely stretch edge-to-edge. Treat gridlines as one continuous page structure. A gridline group must not look like an isolated four-sided box floating inside the page gutters. Examples of this are in the Flexsuite recipes. - **Do** — keep heroes, `River`s, `ComparisonTable` / `PricingOptions`, connected Card or Pillar groups, forms, `CTABanner`s, and prose on the same `Grid` / `Section` column. Draw thin side rules on the column and full-bleed horizontal rules between major sections. -- **Do** — for connected panels, put block rules on the outer frame, outer inline rules on the centered content container, and internal dividers on zero-gap cell wrappers. Preserve these intersections when max-width or column count changes at responsive breakpoints. -- **Do** — give a panel frame's block-start and block-end rules the same extent. They may differ only when one rule is also a page-wide section boundary that intersects the frame's inline rules. +- **Do** — for connected `Card` and `Pillar` groups, put block-start and block-end rules on a page-width outer frame outside the centered, max-width `Grid` / `Section`. Put outer inline rules on the centered content container and internal dividers on zero-gap cell wrappers. +- **Do** — for these custom group frames, make every horizontal rule reach both viewport edges. It may instead end at vertical page-grid rules only when those rules continue beyond the group into adjacent sections. +- **Do** — preserve rule intersections when the max-width or column count changes at responsive breakpoints. +- **Don't** — put all four outer borders for a `Card` or `Pillar` group on a centered, max-width container. This creates a disconnected box with empty margins around it. +- **Don't** — assume `width: 100%` makes a custom group frame full bleed when it is inside a constrained parent. The outer frame must sit outside that parent or use the page's established full-bleed breakout. - **Don't** — give sections different widths, add gaps between borders that should connect, or put borders on inset wrappers where their endpoints stop in empty space. - **Don't** — stretch content edge-to-edge or let a section bleed full-width unless it is a deliberate background band behind the shared grid. +Retrieve the canonical grouped `Card` or `Pillar` structure with `primer_brand_examples` and carry its companion CSS with the JSX. Do not reproduce that structure from this prose. + - **Mobile** — use a modest, consistent side inset and make sure nothing overflows the viewport. Resolve gutter, inset, and gridline (border) values with `primer_brand_tokens`; don't hardcode hex or pixel values. @@ -63,7 +70,9 @@ Generous spacing is what lets a layout breathe; the Flexsuite recipes are a good A page has one clear headline and a calm step-down from there. +- **Do** — use the default `size` of component-owned text such as `Card.Heading`, `Hero.Heading`, and `FAQ.Heading`. Set `size` on standalone `Heading` and `Text`. - **Do** — use a single hero heading, make secondary section headings a clear step smaller, and keep body copy regular weight. Left-align long-form copy; reserve centering for short hero or section intros. +- **Don't** — pass `size` to a component-owned heading, description, label, or other text unless the brief requires an override. Changing `as` for semantic heading order does not require changing `size`. - **Don't** — size secondary headings close to the hero, set body text in heavy weights, or center long paragraphs. Resolve exact sizes and weights with `primer_brand_tokens`. @@ -126,14 +135,15 @@ Resolve exact sizes and weights with `primer_brand_tokens`. **Do** -- Default to placing `Card`, `Pillar`, `Box`, or custom items in a square frame using ``. +- Default to placing connected `Card` or `Pillar` groups in a page-width outer frame around a centered zero-gap `Grid`. Retrieve the current structure and companion CSS with `primer_brand_examples`. - When Card images need to stretch to fill the Card width, set `padding="none"` on every `Card.Image` and `hasBorder` on every `Card` to provide visual containment. Place these full-bleed Cards in a regular `Grid` with a clear gap between them; this is a separate visual treatment from gridline Cards. Keep image position and aspect ratio consistent across the group, and retrieve Card examples with `primer_brand_examples` for reference. - Draw shared dividers on custom frame/cell wrappers and use `border-radius: 0` there. **Don't** - Put native-bordered, full-bleed Cards inside a shared gridline frame, or mix gridline and native-border treatments within one group. -- Render other repeated panels as separate rounded cards, double their shared borders, or override `Card` / `Pillar` internals. +- Apply the custom Card/Pillar frame pattern to components that already provide a gridline variant or prop. +- Box a repeated panel group inside the centered content width, render panels as separate rounded cards, double their shared borders, or override `Card` / `Pillar` internals. - `Pillar` has no grid variant; don't invent one. ### CTABanner diff --git a/packages/mcp/scripts/generate-catalog.mjs b/packages/mcp/scripts/generate-catalog.mjs index 6d88a7ad14..3f88a38ad9 100644 --- a/packages/mcp/scripts/generate-catalog.mjs +++ b/packages/mcp/scripts/generate-catalog.mjs @@ -264,7 +264,7 @@ function bestStoryExample(directory, name, byName) { const declarationBody = source.slice(exportMatches[index].index, exportMatches[index + 1]?.index ?? source.length) for (const match of declarationBody.matchAll(/(?:=>|\breturn)\s*\(/g)) { const capturedJsx = captureBalanced(declarationBody, match.index + match[0].length - 1, '(', ')') - if (capturedJsx && capturedJsx.includes('<')) snippets.push(capturedJsx) + if (capturedJsx && capturedJsx.includes('<')) snippets.push({capturedJsx, exportName: exportMatches[index][1]}) } } return snippets @@ -299,9 +299,11 @@ function bestStoryExample(directory, name, byName) { } // Must render the component; sub-composition helps, plumbing/length hurt. -1 rejects. - const scoreSnippet = (code, fromExamplesFile) => { + const scoreSnippet = (code, fromExamplesFile, exportName) => { if (!new RegExp(`<${name}(?:\\b|\\.)`).test(code)) return -1 let total = fromExamplesFile ? 3 : 0 + // An exact `Gridline` story is the component's focused canonical composition, not a mixed showcase. + if (/^gridline$/i.test(exportName)) total += 10 total += (code.match(new RegExp(`<${name}\\.`, 'g')) || []).length for (const pattern of EXAMPLE_NOISE) total -= (code.match(pattern) || []).length if (code.length < 40) total -= 5 @@ -317,10 +319,10 @@ function bestStoryExample(directory, name, byName) { const source = readFileOrNull(file) if (!source) continue const fromExamplesFile = /\.examples?\.stories\.tsx$/i.test(file) - for (const capturedJsx of jsxSnippets(source)) { + for (const {capturedJsx, exportName} of jsxSnippets(source)) { const code = dedentJsx(capturedJsx) if (code.length > 4000 || exampleContradictsCatalog(code, byName)) continue - const relevanceScore = scoreSnippet(code, fromExamplesFile) + const relevanceScore = scoreSnippet(code, fromExamplesFile, exportName) if (relevanceScore >= 0 && (!bestExample || relevanceScore > bestExample.score)) bestExample = {code, styles: referencedStyles(file, source, code), score: relevanceScore} } diff --git a/packages/mcp/scripts/smoke.mjs b/packages/mcp/scripts/smoke.mjs index 040e46e4ba..db803bb462 100644 --- a/packages/mcp/scripts/smoke.mjs +++ b/packages/mcp/scripts/smoke.mjs @@ -6,12 +6,27 @@ */ import {Client} from '@modelcontextprotocol/sdk/client/index.js' import {StdioClientTransport} from '@modelcontextprotocol/sdk/client/stdio.js' +import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {dirname, join} from 'node:path' +import {fileURLToPath} from 'node:url' function assert(condition, message) { if (!condition) throw new Error(message) } -const transport = new StdioClientTransport({command: 'node', args: ['dist/index.js']}) +const packageDir = dirname(dirname(fileURLToPath(import.meta.url))) +const smokeProject = mkdtempSync(join(tmpdir(), 'primer-brand-mcp-smoke-')) +writeFileSync( + join(smokeProject, 'package.json'), + JSON.stringify({dependencies: {vite: '^7.0.0', '@primer/react-brand': '^0.73.0'}}), +) + +const transport = new StdioClientTransport({ + command: 'node', + args: [join(packageDir, 'dist', 'index.js')], + cwd: smokeProject, +}) const client = new Client({name: 'primer-brand-mcp-smoke', version: '0.0.0'}) try { @@ -32,7 +47,15 @@ try { return text } - await call('primer_brand_setup', {framework: 'next-app'}) + const setup = await call('primer_brand_setup', {framework: 'next-app'}) + assert( + setup.includes('Created `AGENTS.md` beside the selected package.json'), + 'primer_brand_setup did not report creating AGENTS.md', + ) + assert( + readFileSync(join(smokeProject, 'AGENTS.md'), 'utf8').includes(''), + 'primer_brand_setup did not create the managed AGENTS.md block', + ) await call('primer_brand_page_design', {}) await call('primer_brand_component', {name: 'Hero'}) await call('primer_brand_examples', {goal: 'education landing page'}) @@ -60,4 +83,5 @@ try { } catch { // ignore errors while closing the client } + rmSync(smokeProject, {recursive: true, force: true}) } diff --git a/packages/mcp/src/brand/detect-asset-generator.test.ts b/packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.test.ts similarity index 100% rename from packages/mcp/src/brand/detect-asset-generator.test.ts rename to packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.test.ts diff --git a/packages/mcp/src/brand/detect-asset-generator.ts b/packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.ts similarity index 100% rename from packages/mcp/src/brand/detect-asset-generator.ts rename to packages/mcp/src/brand/detect-asset-generator/detect-asset-generator.ts diff --git a/packages/mcp/src/brand/detect-framework.test.ts b/packages/mcp/src/brand/detect-framework/detect-framework.test.ts similarity index 100% rename from packages/mcp/src/brand/detect-framework.test.ts rename to packages/mcp/src/brand/detect-framework/detect-framework.test.ts diff --git a/packages/mcp/src/brand/detect-framework.ts b/packages/mcp/src/brand/detect-framework/detect-framework.ts similarity index 100% rename from packages/mcp/src/brand/detect-framework.ts rename to packages/mcp/src/brand/detect-framework/detect-framework.ts diff --git a/packages/mcp/src/brand/docs-source.ts b/packages/mcp/src/brand/docs-source/docs-source.ts similarity index 96% rename from packages/mcp/src/brand/docs-source.ts rename to packages/mcp/src/brand/docs-source/docs-source.ts index edf9f68091..3dd71de009 100644 --- a/packages/mcp/src/brand/docs-source.ts +++ b/packages/mcp/src/brand/docs-source/docs-source.ts @@ -1,8 +1,8 @@ import {existsSync, readFileSync, realpathSync} from 'node:fs' import {dirname, join, normalize, resolve, sep} from 'node:path' -import type {Logger} from '../logger.js' -import type {BrandInstall} from './resolve-install.js' +import type {Logger} from '../../logger.js' +import type {BrandInstall} from '../resolve-install/resolve-install.js' /** * One entry in the documentation table of contents (parsed from `llms.txt`). diff --git a/packages/mcp/src/brand/resolve-assets.test.ts b/packages/mcp/src/brand/resolve-assets/resolve-assets.test.ts similarity index 100% rename from packages/mcp/src/brand/resolve-assets.test.ts rename to packages/mcp/src/brand/resolve-assets/resolve-assets.test.ts diff --git a/packages/mcp/src/brand/resolve-assets.ts b/packages/mcp/src/brand/resolve-assets/resolve-assets.ts similarity index 98% rename from packages/mcp/src/brand/resolve-assets.ts rename to packages/mcp/src/brand/resolve-assets/resolve-assets.ts index 9c4cd21fae..dd470782c8 100644 --- a/packages/mcp/src/brand/resolve-assets.ts +++ b/packages/mcp/src/brand/resolve-assets/resolve-assets.ts @@ -1,7 +1,7 @@ import {existsSync, readdirSync, readFileSync} from 'node:fs' import {dirname, join} from 'node:path' -import type {CatalogAsset} from '../catalog/types.js' +import type {CatalogAsset} from '../../catalog/types.js' type AssetPackage = { module: string diff --git a/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts new file mode 100644 index 0000000000..76afc825cf --- /dev/null +++ b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.test.ts @@ -0,0 +1,87 @@ +import {mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {resolveBrandProject} from './resolve-brand-project.js' + +describe('resolveBrandProject', () => { + const directories: string[] = [] + const temporaryWorkspace = () => { + const directory = realpathSync(mkdtempSync(join(tmpdir(), 'primer-brand-project-'))) + directories.push(directory) + return directory + } + const writePackage = (directory: string, dependencies: Record = {}) => { + mkdirSync(directory, {recursive: true}) + writeFileSync(join(directory, 'package.json'), JSON.stringify({dependencies})) + } + + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true}) + }) + + it('finds the only nested package declaring Primer Brand', () => { + const workspace = temporaryWorkspace() + writePackage(workspace, {react: '^19.0.0'}) + writePackage(join(workspace, 'apps', 'site'), {'@primer/react-brand': '^0.73.0'}) + writePackage(join(workspace, 'apps', 'api'), {express: '^5.0.0'}) + + expect(resolveBrandProject(workspace)).toEqual({ + projectDir: join(workspace, 'apps', 'site'), + candidates: [join(workspace, 'apps', 'site')], + }) + }) + + it('reports ambiguity when several packages declare Primer Brand', () => { + const workspace = temporaryWorkspace() + writePackage(join(workspace, 'apps', 'docs'), {'@primer/react-brand': '^0.73.0'}) + writePackage(join(workspace, 'apps', 'site'), {'@primer/react-brand': '^0.73.0'}) + + expect(resolveBrandProject(workspace)).toMatchObject({ + projectDir: null, + reason: 'ambiguous-brand-project', + candidates: [join(workspace, 'apps', 'docs'), join(workspace, 'apps', 'site')], + }) + }) + + it('uses an explicitly requested declaring package', () => { + const workspace = temporaryWorkspace() + writePackage(join(workspace, 'apps', 'docs'), {'@primer/react-brand': '^0.73.0'}) + writePackage(join(workspace, 'apps', 'site'), {'@primer/react-brand': '^0.73.0'}) + + expect(resolveBrandProject(workspace, 'apps/site')).toEqual({ + projectDir: join(workspace, 'apps', 'site'), + candidates: [join(workspace, 'apps', 'site')], + }) + }) + + it('rejects requested folders outside the workspace or without the dependency', () => { + const workspace = temporaryWorkspace() + writePackage(join(workspace, 'apps', 'site'), {react: '^19.0.0'}) + + expect(resolveBrandProject(workspace, 'apps/site').reason).toBe('invalid-brand-project') + expect(resolveBrandProject(workspace, '..').reason).toBe('invalid-brand-project') + expect(resolveBrandProject(workspace, join(workspace, 'apps', 'site')).reason).toBe('invalid-brand-project') + }) + + it('rejects a requested package symlink that resolves outside the workspace', () => { + const workspace = temporaryWorkspace() + const externalPackage = temporaryWorkspace() + writePackage(externalPackage, {'@primer/react-brand': '^0.73.0'}) + mkdirSync(join(workspace, 'apps'), {recursive: true}) + symlinkSync(externalPackage, join(workspace, 'apps', 'site'), 'dir') + + expect(resolveBrandProject(workspace, 'apps/site')).toMatchObject({ + projectDir: null, + reason: 'invalid-brand-project', + }) + }) + + it('reports when no declaring package or workspace root is available', () => { + const workspace = temporaryWorkspace() + writePackage(workspace, {react: '^19.0.0'}) + + expect(resolveBrandProject(workspace).reason).toBe('brand-package-not-found') + expect(resolveBrandProject(null).reason).toBe('no-workspace-root') + }) +}) diff --git a/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts new file mode 100644 index 0000000000..4659e8b0f5 --- /dev/null +++ b/packages/mcp/src/brand/resolve-brand-project/resolve-brand-project.ts @@ -0,0 +1,88 @@ +import {readdirSync, readFileSync, realpathSync} from 'node:fs' +import {isAbsolute, join, relative, resolve, sep} from 'node:path' + +export type BrandProjectResolution = { + projectDir: string | null + candidates: string[] + reason?: 'no-workspace-root' | 'brand-package-not-found' | 'ambiguous-brand-project' | 'invalid-brand-project' +} + +/** Resolve the package folder whose package.json directly declares @primer/react-brand. */ +export function resolveBrandProject(workspaceDir: string | null, requestedProjectDir?: string): BrandProjectResolution { + const dependencyName = '@primer/react-brand' + const ignoredDirectories = new Set([ + '.git', + '.next', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'out', + 'storybook-static', + ]) + + const declaresBrand = (directory: string): boolean => { + try { + const manifest = JSON.parse(readFileSync(join(directory, 'package.json'), 'utf8')) as Record< + string, + Record | undefined + > + return ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'].some(group => + Boolean(manifest[group]?.[dependencyName]), + ) + } catch { + return false + } + } + + if (!workspaceDir) return {projectDir: null, candidates: [], reason: 'no-workspace-root'} + + let canonicalWorkspaceDir: string + try { + canonicalWorkspaceDir = realpathSync(workspaceDir) + } catch { + return {projectDir: null, candidates: [], reason: 'no-workspace-root'} + } + + const isWithinWorkspace = (candidate: string): boolean => { + const relativeCandidate = relative(canonicalWorkspaceDir, candidate) + return relativeCandidate !== '..' && !relativeCandidate.startsWith(`..${sep}`) && !isAbsolute(relativeCandidate) + } + + if (requestedProjectDir) { + if (isAbsolute(requestedProjectDir)) { + return {projectDir: null, candidates: [], reason: 'invalid-brand-project'} + } + let canonicalCandidate: string + try { + canonicalCandidate = realpathSync(resolve(canonicalWorkspaceDir, requestedProjectDir)) + } catch { + return {projectDir: null, candidates: [], reason: 'invalid-brand-project'} + } + return isWithinWorkspace(canonicalCandidate) && declaresBrand(canonicalCandidate) + ? {projectDir: canonicalCandidate, candidates: [canonicalCandidate]} + : {projectDir: null, candidates: [], reason: 'invalid-brand-project'} + } + + const candidates: string[] = [] + const visit = (directory: string): void => { + if (declaresBrand(directory)) candidates.push(directory) + + let entries + try { + entries = readdirSync(directory, {withFileTypes: true}) + } catch { + return + } + for (const entry of entries) { + if (!entry.isDirectory() || ignoredDirectories.has(entry.name)) continue + visit(join(directory, entry.name)) + } + } + visit(canonicalWorkspaceDir) + candidates.sort() + + if (candidates.length === 1) return {projectDir: candidates[0] ?? null, candidates} + if (candidates.length > 1) return {projectDir: null, candidates, reason: 'ambiguous-brand-project'} + return {projectDir: null, candidates, reason: 'brand-package-not-found'} +} diff --git a/packages/mcp/src/brand/resolve-install.ts b/packages/mcp/src/brand/resolve-install/resolve-install.ts similarity index 100% rename from packages/mcp/src/brand/resolve-install.ts rename to packages/mcp/src/brand/resolve-install/resolve-install.ts diff --git a/packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts b/packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts new file mode 100644 index 0000000000..16c5a77495 --- /dev/null +++ b/packages/mcp/src/brand/update-agents-md/update-agents-md.test.ts @@ -0,0 +1,104 @@ +import {existsSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {updateAgentsMd} from './update-agents-md.js' + +describe('updateAgentsMd', () => { + const directories: string[] = [] + const temporaryProject = () => { + const directory = mkdtempSync(join(tmpdir(), 'primer-brand-agents-')) + directories.push(directory) + return directory + } + + afterEach(() => { + for (const directory of directories.splice(0)) rmSync(directory, {recursive: true, force: true}) + }) + + it('creates AGENTS.md with a lightweight MCP and local-docs pointer', () => { + const projectDir = temporaryProject() + expect(updateAgentsMd(projectDir).action).toBe('created') + + const content = readFileSync(join(projectDir, 'AGENTS.md'), 'utf8') + expect(content).toContain('') + expect(content).toContain('`primer_brand_docs`') + expect(content).toContain('version-matched documentation bundled with the installed package') + expect(content).toContain('Start page work with `primer_brand_page_design` and `primer_brand_examples`') + expect(content).not.toContain('`primer_brand_setup`') + expect(content).not.toContain('skill') + }) + + it('preserves existing content and appends the managed block', () => { + const projectDir = temporaryProject() + writeFileSync(join(projectDir, 'AGENTS.md'), '# Existing instructions\n\nKeep this.\n') + + expect(updateAgentsMd(projectDir).action).toBe('updated') + const content = readFileSync(join(projectDir, 'AGENTS.md'), 'utf8') + expect(content.startsWith('# Existing instructions\n\nKeep this.\n')).toBe(true) + expect(content.match(/BEGIN:primer-brand-mcp/g)).toHaveLength(1) + }) + + it('updates only its managed block and is idempotent', () => { + const projectDir = temporaryProject() + const file = join(projectDir, 'AGENTS.md') + writeFileSync( + file, + '# Existing\n\n\nOld instructions\n\n\n# End\n', + ) + + expect(updateAgentsMd(projectDir).action).toBe('updated') + expect(updateAgentsMd(projectDir).action).toBe('unchanged') + const content = readFileSync(file, 'utf8') + expect(content).toContain('# Existing') + expect(content).toContain('# End') + expect(content).not.toContain('Old instructions') + expect(content.match(/BEGIN:primer-brand-mcp/g)).toHaveLength(1) + }) + + it('preserves CRLF line endings', () => { + const projectDir = temporaryProject() + const file = join(projectDir, 'AGENTS.md') + writeFileSync(file, '# Existing\r\n') + + updateAgentsMd(projectDir) + const content = readFileSync(file, 'utf8') + expect(content).toContain('\r\n\r\n') + expect(content.replaceAll('\r\n', '')).not.toContain('\n') + }) + + it('skips malformed blocks and incorrectly cased files', () => { + const malformedDir = temporaryProject() + writeFileSync(join(malformedDir, 'AGENTS.md'), '\nDo not replace') + expect(updateAgentsMd(malformedDir)).toMatchObject({action: 'skipped', reason: 'malformed-managed-block'}) + + const wrongCaseDir = temporaryProject() + writeFileSync(join(wrongCaseDir, 'agents.md'), '# Existing') + expect(updateAgentsMd(wrongCaseDir)).toMatchObject({action: 'skipped', reason: 'incorrect-filename-case'}) + }) + + it('does not read or write AGENTS.md through symbolic links', () => { + const projectDir = temporaryProject() + const externalDir = temporaryProject() + const externalFile = join(externalDir, 'instructions.md') + writeFileSync(externalFile, '# External instructions\n') + symlinkSync(externalFile, join(projectDir, 'AGENTS.md')) + + expect(updateAgentsMd(projectDir)).toMatchObject({action: 'skipped', reason: 'unsafe-agents-path'}) + expect(readFileSync(externalFile, 'utf8')).toBe('# External instructions\n') + }) + + it('does not create an external file through a dangling AGENTS.md symlink', () => { + const projectDir = temporaryProject() + const externalDir = temporaryProject() + const externalFile = join(externalDir, 'future-instructions.md') + symlinkSync(externalFile, join(projectDir, 'AGENTS.md')) + + expect(updateAgentsMd(projectDir)).toMatchObject({action: 'skipped', reason: 'unsafe-agents-path'}) + expect(existsSync(externalFile)).toBe(false) + }) + + it('skips when no project root is available', () => { + expect(updateAgentsMd(null)).toEqual({action: 'skipped', path: null, reason: 'no-project-root'}) + }) +}) diff --git a/packages/mcp/src/brand/update-agents-md/update-agents-md.ts b/packages/mcp/src/brand/update-agents-md/update-agents-md.ts new file mode 100644 index 0000000000..ff1dd6decd --- /dev/null +++ b/packages/mcp/src/brand/update-agents-md/update-agents-md.ts @@ -0,0 +1,88 @@ +import {lstatSync, readFileSync, readdirSync, realpathSync, writeFileSync} from 'node:fs' +import {isAbsolute, join, relative, sep} from 'node:path' + +export type AgentsMdUpdateResult = { + action: 'created' | 'updated' | 'unchanged' | 'skipped' + path: string | null + reason?: 'no-project-root' | 'incorrect-filename-case' | 'malformed-managed-block' | 'unsafe-agents-path' +} + +/** Create or update the managed Primer Brand instructions in a project's root AGENTS.md. */ +export function updateAgentsMd(projectDir: string | null): AgentsMdUpdateResult { + const filename = 'AGENTS.md' + const startMarker = '' + const endMarker = '' + const block = `${startMarker} +## Primer Brand + +When building brand-led marketing or landing pages with \`@primer/react-brand\`, use the available \`primer_brand_*\` MCP tools. + +Prefer \`primer_brand_docs\` over fetching primer.style/brand directly. It reads version-matched documentation bundled with the installed package first and only falls back to the live site when necessary. + +Start page work with \`primer_brand_page_design\` and \`primer_brand_examples\`. Use \`primer_brand_component\` for exact APIs and \`primer_brand_review\` before finishing. +${endMarker}` + + if (!projectDir) return {action: 'skipped', path: null, reason: 'no-project-root'} + + let canonicalProjectDir: string + try { + canonicalProjectDir = realpathSync(projectDir) + } catch { + return {action: 'skipped', path: null, reason: 'no-project-root'} + } + + const agentMdCustomSetupPath = join(canonicalProjectDir, filename) + const caseInsensitiveMatch = readdirSync(canonicalProjectDir).find( + entry => entry.toLowerCase() === filename.toLowerCase(), + ) + if (caseInsensitiveMatch && caseInsensitiveMatch !== filename) { + return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'incorrect-filename-case'} + } + + const fileStats = lstatSync(agentMdCustomSetupPath, {throwIfNoEntry: false}) + if (fileStats?.isSymbolicLink()) { + return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'} + } + + if (!fileStats) { + writeFileSync(agentMdCustomSetupPath, `${block}\n`, 'utf8') + return {action: 'created', path: agentMdCustomSetupPath} + } + + let canonicalAgentsPath: string + try { + canonicalAgentsPath = realpathSync(agentMdCustomSetupPath) + } catch { + return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'} + } + const relativeAgentsPath = relative(canonicalProjectDir, canonicalAgentsPath) + if (relativeAgentsPath === '..' || relativeAgentsPath.startsWith(`..${sep}`) || isAbsolute(relativeAgentsPath)) { + return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'unsafe-agents-path'} + } + + const existing = readFileSync(agentMdCustomSetupPath, 'utf8') + const eol = existing.includes('\r\n') ? '\r\n' : '\n' + const normalizedBlock = block.replaceAll('\n', eol) + const startIndex = existing.indexOf(startMarker) + const endIndex = existing.indexOf(endMarker) + const hasStartMarker = startIndex !== -1 + const hasEndMarker = endIndex !== -1 + const hasDuplicateMarkers = + startIndex !== existing.lastIndexOf(startMarker) || endIndex !== existing.lastIndexOf(endMarker) + + if (hasStartMarker !== hasEndMarker || hasDuplicateMarkers || (hasStartMarker && endIndex < startIndex)) { + return {action: 'skipped', path: agentMdCustomSetupPath, reason: 'malformed-managed-block'} + } + + let updated: string + if (hasStartMarker) { + updated = `${existing.slice(0, startIndex)}${normalizedBlock}${existing.slice(endIndex + endMarker.length)}` + } else { + const separator = existing.length === 0 || /\r?\n$/.test(existing) ? eol : `${eol}${eol}` + updated = `${existing}${separator}${normalizedBlock}${eol}` + } + + if (updated === existing) return {action: 'unchanged', path: agentMdCustomSetupPath} + writeFileSync(agentMdCustomSetupPath, updated, 'utf8') + return {action: 'updated', path: agentMdCustomSetupPath} +} diff --git a/packages/mcp/src/review/rules.test.ts b/packages/mcp/src/review/rules.test.ts index df30bb1838..8ec85aac11 100644 --- a/packages/mcp/src/review/rules.test.ts +++ b/packages/mcp/src/review/rules.test.ts @@ -214,6 +214,30 @@ describe('primer_brand_review rules', () => { expect(ruleIds(review('Big'))).not.toContain('heading-explicit-size') }) + it('flags size overrides on component-owned text', () => { + expect(ruleIds(review('Infrastructure'))).toContain( + 'component-text-default-size', + ) + expect(ruleIds(review('Description'))).toContain( + 'component-text-default-size', + ) + }) + + it('allows component-owned text defaults and standalone typography sizes', () => { + expect(ruleIds(review('Questions'))).not.toContain('component-text-default-size') + expect(ruleIds(review('Heading'))).not.toContain( + 'component-text-default-size', + ) + expect(ruleIds(review('Description'))).not.toContain('component-text-default-size') + expect(ruleIds(review('Heading'))).not.toContain('component-text-default-size') + }) + + it('does not apply component-owned text guidance to application components', () => { + expect(ruleIds(review('Pro'))).not.toContain( + 'component-text-default-size', + ) + }) + it('reports which approved brand components were imported', () => { const used = brandComponentsUsed("import {Hero, CTABanner} from '@primer/react-brand'", makeCatalog()) expect(used.map(component => component.name).sort()).toEqual(['CTABanner', 'Hero']) @@ -264,6 +288,19 @@ describe('primer_brand_review over generated canonical examples', () => { expect(example?.code).not.toContain('color="purple"') }) + it('uses the page-width gridline Statistic example with its companion styles', () => { + const statistic = catalog.components.find(component => component.name === 'Statistic') + const example = statistic?.examples[0] + expect(example?.code).toContain('') + expect(example?.code).toContain('columnGap="none"') + expect(example?.code).toContain('rowGap="none"') + expect(example?.code).toContain('enableGutters={false}') + expect(example?.styles).toContain('.gridFrame') + expect(example?.styles).toContain('border-block-start') + expect(example?.styles).toContain('.gridContent') + expect(example?.styles).toContain('margin-inline: auto') + }) + it('produces no errors on any catalog example', () => { expect(examples.length).toBeGreaterThan(0) for (const example of examples) { diff --git a/packages/mcp/src/review/rules.ts b/packages/mcp/src/review/rules.ts index c27deb19e2..ecbb943072 100644 --- a/packages/mcp/src/review/rules.ts +++ b/packages/mcp/src/review/rules.ts @@ -438,6 +438,28 @@ const headingExplicitSize: Rule = { }, } +/** Compound components provide context-appropriate text sizes; explicit overrides should be exceptional. */ +const componentTextDefaultSize: Rule = { + id: 'component-text-default-size', + run(code, catalog) { + const knownSubcomponents = new Set(catalog.components.flatMap(component => component.subcomponents)) + const findings: Finding[] = [] + for (const match of code.matchAll( + /<([A-Z][A-Za-z0-9]*)\.(Heading|Subheading|Description|Label|Eyebrow|Text)\b[^>]*(?:^|\s)size\s*=/g, + )) { + const qualifiedName = `${match[1]}.${match[2]}` + if (!knownSubcomponents.has(qualifiedName)) continue + findings.push({ + severity: 'warning', + rule: this.id, + message: `\`${qualifiedName}\` already has the right default \`size\` for its component. Remove \`size\` unless the brief requires an override.`, + evidence: evidence(match[0]), + }) + } + return findings + }, +} + /** Credit for actually importing approved brand components — surfaced as guidance, not a failure. */ export function brandComponentsUsed(code: string, catalog: Catalog): CatalogComponent[] { const imported = new Set() @@ -467,4 +489,5 @@ export const allRules: Rule[] = [ hardcodedValues, offBrandTells, headingExplicitSize, + componentTextDefaultSize, ] diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e1c809fa72..4c76a00e04 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -3,10 +3,11 @@ import {fileURLToPath} from 'node:url' import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js' -import {detectAssetGenerator} from './brand/detect-asset-generator.js' -import {createDocsSource} from './brand/docs-source.js' -import {resolveInstalledAssets} from './brand/resolve-assets.js' -import {looksLikeProductProject, resolveBrandInstall} from './brand/resolve-install.js' +import {detectAssetGenerator} from './brand/detect-asset-generator/detect-asset-generator.js' +import {detectFramework} from './brand/detect-framework/detect-framework.js' +import {createDocsSource} from './brand/docs-source/docs-source.js' +import {resolveInstalledAssets} from './brand/resolve-assets/resolve-assets.js' +import {looksLikeProductProject, resolveBrandInstall} from './brand/resolve-install/resolve-install.js' import {loadCatalog} from './catalog/load.js' import {createLogger} from './logger.js' import {registerTools} from './tools/register.js' @@ -48,6 +49,7 @@ export function createServer(): McpServer { const catalog = loadCatalog(logger) const brand = resolveBrandInstall() + const framework = detectFramework() if (brand.found) { logger.info(`using @primer/react-brand@${brand.version ?? 'unknown'} from ${brand.packageDir}`) } else if (looksLikeProductProject()) { @@ -69,6 +71,16 @@ export function createServer(): McpServer { } const docs = createDocsSource(brand, logger) - registerTools(server, {catalog, brand, docs, logger, assets, assetsOrigin, assetGenerator: detectAssetGenerator()}) + registerTools(server, { + catalog, + brand, + workspaceDir: process.cwd(), + framework, + docs, + logger, + assets, + assetsOrigin, + assetGenerator: detectAssetGenerator(), + }) return server } diff --git a/packages/mcp/src/test-utils/catalog.ts b/packages/mcp/src/test-utils/catalog.ts index 853402462a..71f919d451 100644 --- a/packages/mcp/src/test-utils/catalog.ts +++ b/packages/mcp/src/test-utils/catalog.ts @@ -1,6 +1,6 @@ import {createLogger} from '../logger.js' -import type {BrandInstall} from '../brand/resolve-install.js' -import type {DocsSource} from '../brand/docs-source.js' +import type {BrandInstall} from '../brand/resolve-install/resolve-install.js' +import type {DocsSource} from '../brand/docs-source/docs-source.js' import type {Catalog} from '../catalog/types.js' import type {ToolContext} from '../tools/types.js' @@ -61,6 +61,13 @@ export function makeCatalog(overrides: Partial = {}): Catalog { }, ], }, + { + name: 'Card', + module: '@primer/react-brand', + subcomponents: ['Card.Heading', 'Card.Description'], + props: [], + examples: [], + }, {name: 'Pillar', module: '@primer/react-brand', subcomponents: [], props: [], examples: []}, {name: 'SectionIntro', module: '@primer/react-brand', subcomponents: [], props: [], examples: []}, {name: 'River', module: '@primer/react-brand', subcomponents: [], props: [], examples: []}, @@ -196,6 +203,8 @@ export function makeContext(overrides: Partial = {}): ToolContext { return { catalog, brand: installedBrand, + workspaceDir: null, + framework: {id: 'unknown', label: 'a React project', rsc: false, projectDir: null}, docs: noopDocs, logger: createLogger(false), assets: catalog.assets, diff --git a/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts b/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts index 1a28289ac5..4e85d606b7 100644 --- a/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts +++ b/packages/mcp/src/tools/primer-brand-asset/primer-brand-asset.ts @@ -14,7 +14,9 @@ const inputSchema = z.object({ type Input = z.infer -const description = `Find approved Primer Brand visuals as code imports: Octicons (@primer/octicons-react) and Octovisuals (@primer/octovisuals-react). Use these instead of emoji, clip art, or random icon sets - mismatched or emoji icons are off-brand.` +const description = `Find approved Primer Brand visuals as code imports: +Octicons (@primer/octicons-react) and Octovisuals (@primer/octovisuals-react). +Use these instead of emoji, clip art, or random icon sets - mismatched or emoji icons are off-brand.` export const primerBrandAssetTool: ToolModule = { name: 'primer_brand_asset', diff --git a/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts b/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts index cf49485e69..4fb1d02aa9 100644 --- a/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts +++ b/packages/mcp/src/tools/primer-brand-component/primer-brand-component.ts @@ -14,7 +14,13 @@ const inputSchema = z.object({ type Input = z.infer -const description = `Primer Brand (@primer/react-brand) component reference for GitHub marketing and landing pages. Omit \`name\` to list every approved component; pass a \`name\` for its import, sub-components, props with allowed values, and a canonical example. The example is a real story snippet, so it may include Storybook \`{...args}\` spreads or imported demo assets to adapt rather than copy verbatim. Use this to avoid invented components or props. This is Primer Brand, not @primer/react product UI.` +const description = `Primer Brand (@primer/react-brand) component reference for GitHub marketing and landing pages. +Omit \`name\` to list every approved component. +Pass a \`name\` for its import, sub-components, props with allowed values, and a canonical example. +The example is a real story snippet, so it may include Storybook \`{...args}\` spreads +or imported demo assets to adapt rather than copy verbatim. +Use this to avoid invented components or props. +This is Primer Brand, not @primer/react product UI.` function listComponents(ctx: ToolContext): ToolResult { const {components} = ctx.catalog diff --git a/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts b/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts index ecebbfc2cd..8326c01c67 100644 --- a/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts +++ b/packages/mcp/src/tools/primer-brand-docs/primer-brand-docs.ts @@ -11,7 +11,10 @@ const inputSchema = z.object({ type Input = z.infer -const description = `Search and read Primer Brand guidance — principles, accessibility, content, and getting started. Prefers the version-pinned docs bundled with the installed @primer/react-brand, falling back to the live site (https://primer.style/brand) for general guidance. This is brand concepts and how-to, not component APIs (use primer_brand_component for those).` +const description = `Search and read Primer Brand guidance — principles, accessibility, content, and getting started. +Prefers the version-pinned docs bundled with the installed @primer/react-brand, +falling back to the live site (https://primer.style/brand) for general guidance. +This is brand concepts and how-to, not component APIs (use primer_brand_component for those).` async function readPath(path: string, ctx: ToolContext): Promise { const doc = await ctx.docs.read(path) diff --git a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts index 7d0135dc63..dcfdf9fe9e 100644 --- a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts +++ b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.test.ts @@ -2,6 +2,15 @@ import {makeContext} from '../../test-utils/catalog.js' import {primerBrandExamplesTool} from './primer-brand-examples.js' describe('primer_brand_examples', () => { + it('describes when to call the tool without duplicating adaptation guidance', () => { + expect(primerBrandExamplesTool.description).toContain('ranked, tested Primer Brand examples') + expect(primerBrandExamplesTool.description).toContain('component or page goal') + expect(primerBrandExamplesTool.description).toContain('primer_brand_component') + expect(primerBrandExamplesTool.description).not.toContain('demo scaffolding') + expect(primerBrandExamplesTool.description).not.toContain('gridline') + expect(primerBrandExamplesTool.description).not.toContain('Card or Pillar') + }) + it('falls back to the default foundational set when nothing matches the goal', async () => { const result = await primerBrandExamplesTool.run({goal: 'zzzz nonexistent zzzz'}, makeContext()) expect(result.isError).toBeFalsy() @@ -9,6 +18,9 @@ describe('primer_brand_examples', () => { expect(result.text).toContain('Hero') expect(result.text.toLowerCase()).toContain('adapt') expect(result.text).toContain('.heroFrame') + expect(result.text).toContain('## How to use these examples') + expect(result.text).toContain('Use built-in gridline variants or props') + expect(result.text).toContain('grouped Card or Pillar example') }) it('ranks examples by goal', async () => { diff --git a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts index c94707b41c..ab92425809 100644 --- a/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts +++ b/packages/mcp/src/tools/primer-brand-examples/primer-brand-examples.ts @@ -16,9 +16,9 @@ const inputSchema = z.object({ type Input = z.infer -const description = `Get ranked, copy-and-adapt examples of correct Primer Brand usage for a goal. Page goals include a current-brand full-page recipe for overall composition, while independently ranked component examples provide goal-specific detail. -Pass a target use-case like "pricing section", "category page", or "education landing page"; unmatched page types use the general overview recipe, while unmatched component goals use a foundational set. -Examples are real source, so they may carry demo scaffolding (a \`content\` object, internal fixture imports, CSS-module class names, repo-relative imports, \`{...args}\` spreads). Preserve the composition and gridline geometry; adapt the content, assets, and imports. When companion CSS is included, carry its frame and cell rules with the JSX.` +const description = `Get ranked, tested Primer Brand examples for a component or page goal. +Use when you need a starting composition; page goals can include a current-brand full-page reference. +Use \`primer_brand_component\` for exact component APIs.` /** Foundational sections that anchor almost every GitHub landing page, in composition order. */ const DEFAULT_COMPONENTS = ['Hero', 'SectionIntro', 'River', 'Pillar', 'CTABanner'] @@ -42,6 +42,9 @@ export const primerBrandExamplesTool: ToolModule = { inputShape: inputSchema.shape, annotations: {readOnlyHint: true}, run(input, ctx): ToolResult { + const adaptationGuidance = `## How to use these examples +These are real source and may include demo scaffolding such as content objects, fixture imports, CSS-module class names, repo-relative imports, or Storybook argument spreads. Adapt the copy, assets, and imports while preserving component composition and gridline geometry. Use built-in gridline variants or props when available. When a grouped Card or Pillar example includes companion CSS, carry its page-width frame and cell rules with the JSX.` + const goal = input.goal?.trim() || 'landing page' const genericPageTerms = new Set([ 'feature', @@ -112,7 +115,7 @@ export const primerBrandExamplesTool: ToolModule = { specificRecipes.length > 0 ? specificRecipes : useDefaultRecipe && defaultRecipe ? [defaultRecipe] : [] const topRecipe = matchedRecipes[0] - const sections = [`# Examples for "${goal}"`] + const sections = [`# Examples for "${goal}"`, adaptationGuidance] if (topRecipe) { const otherRecipes = matchedRecipes.slice(1) const also = otherRecipes.length @@ -123,7 +126,7 @@ export const primerBrandExamplesTool: ToolModule = { `## Full-page template — ${ useDefaultRecipe ? 'default FlexSuite overview recipe source' : 'goal-matched recipe source' }: ${topRecipe.title}`, - `This is the **actual current-brand recipe source** from \`@primer/react-brand\`, wired for our demo harness: a \`content\` object supplies the copy, imagery comes from internal fixtures, styling uses internal CSS-module class names, and imports are repo-relative. Ignore that scaffolding — mirror the page structure and gridline composition, then use the goal-specific component examples below for deeper context.${also}`, + `This is the **actual current-brand recipe source** from \`@primer/react-brand\`. Use it for overall page composition, then use the goal-specific component examples below for deeper context.${also}`, `\`\`\`tsx\n${topRecipe.source}\n\`\`\``, ].join('\n\n'), ) diff --git a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts index 4c10d8058d..3c8759f417 100644 --- a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts +++ b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.test.ts @@ -2,6 +2,17 @@ import {makeContext} from '../../test-utils/catalog.js' import {primerPageDesignTool} from './primer-brand-page-design.js' describe('primer_brand_page_design', () => { + it('describes when to call the tool without duplicating its guidance', () => { + expect(primerPageDesignTool.description).toContain('Read before building') + expect(primerPageDesignTool.description).toContain('page-level composition guidance') + expect(primerPageDesignTool.description).toContain('current-brand reference templates') + expect(primerPageDesignTool.description).toContain('primer_brand_component') + expect(primerPageDesignTool.description).toContain('primer_brand_review') + expect(primerPageDesignTool.description).not.toContain('SubdomainNavBar') + expect(primerPageDesignTool.description).not.toContain('gridline') + expect(primerPageDesignTool.description).not.toContain('Card/Pillar') + }) + it('returns the bundled page-design guidance', async () => { const result = await primerPageDesignTool.run({}, makeContext()) expect(result.isError).toBeFalsy() @@ -38,9 +49,17 @@ describe('primer_brand_page_design', () => { expect(result.text).toContain('**Do**') expect(result.text).toContain("**Don't**") expect(result.text).toContain('### Repeated panels') - expect(result.text).toContain('Never let a border stop in empty space') - expect(result.text).toContain('block-start and block-end rules the same extent') - expect(result.text).toContain('`Card`, `Pillar`, `Box`, or custom items') + expect(result.text).toContain('must not look like an isolated four-sided box') + expect(result.text).toContain("component's built-in `gridline` variant") + expect(result.text).toContain('recreate built-in gridlines with custom `Box` wrappers') + expect(result.text).toContain('for connected `Card` and `Pillar` groups') + expect(result.text).toContain('make every horizontal rule reach both viewport edges') + expect(result.text).toContain('assume `width: 100%` makes a custom group frame full bleed') + expect(result.text).toContain('Retrieve the canonical grouped `Card` or `Pillar` structure') + expect(result.text).not.toContain('') + expect(result.text).not.toContain('.gridFrame {') + expect(result.text).toContain('connected `Card` or `Pillar` groups') + expect(result.text).toContain('components that already provide a gridline variant or prop') expect(result.text).toContain('border-radius: 0') expect(result.text).toContain('') expect(result.text).toContain('multiple meaningful categories') @@ -56,6 +75,9 @@ describe('primer_brand_page_design', () => { expect(result.text).toContain('token-backed maximum height') expect(result.text).toContain('place custom media after `Hero`') expect(result.text).toContain('use `trailingComponent`') + expect(result.text).toContain('use the default `size` of component-owned text') + expect(result.text).toContain('Set `size` on standalone `Heading` and `Text`') + expect(result.text).toContain('Changing `as` for semantic heading order does not require changing `size`') expect(result.text).toContain('catches some code-level mistakes automatically') expect(result.text).not.toContain('152px') }) diff --git a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts index 4be2a93fa8..ceabd6efc0 100644 --- a/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts +++ b/packages/mcp/src/tools/primer-brand-page-design/primer-brand-page-design.ts @@ -10,7 +10,10 @@ const inputSchema = z.object({}) type Input = z.infer -const description = `Page-design patterns to read before building a page: how to structure and style a full marketing/landing page and use components together correctly (frame with \`SubdomainNavBar\` + \`MinimalFooter\`, give heroes media, group cards inside connected gridlines, keep labels auto-width, bias to the gridline aesthetic) plus the current-brand reference templates to start from. Learn individual component APIs with \`primer_brand_docs\` / \`primer_brand_component\` — this is the page-design layer above them. These conventions \`primer_brand_review\` does not enforce.` +const description = `Read before building a Primer Brand marketing or landing page. +Returns page-level composition guidance and current-brand reference templates for combining components. +Use \`primer_brand_docs\` or \`primer_brand_component\` for individual component APIs. +These visual conventions are not fully enforced by \`primer_brand_review\`.` const guidancePath = fileURLToPath(new URL('../../../content/page-design.md', import.meta.url)) diff --git a/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts b/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts index ae1143e0d0..c35327d22a 100644 --- a/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts +++ b/packages/mcp/src/tools/primer-brand-review/primer-brand-review.ts @@ -16,12 +16,14 @@ const inputSchema = z.object({ type Input = z.infer -const description = `The final on-brand gate. Before you finish, paste your COMPLETE output in one call — the JSX/TSX AND -every stylesheet (App.css, *.module.css, styled blocks) together — because hardcoded sizes/colors and raw HTML most often -hide in CSS. Flags non-compliant components or sub-components, invalid prop values, headings left at their oversized default -(a \`Heading\` with no explicit \`size\`), raw HTML where a brand component exists, hardcoded colors/sizes that should be tokens, -and off-brand visual tells (purple gradients, pill buttons, glassmorphism, placeholder copy). Run it on everything you wrote and -fix what it reports rather than guessing.` +const description = `The final on-brand gate. +Before you finish, paste your COMPLETE output in one call — the JSX/TSX AND every stylesheet +(App.css, *.module.css, styled blocks) together — because hardcoded sizes/colors and raw HTML most often hide in CSS. +Flags non-compliant components or sub-components, invalid prop values, +headings left at their oversized default (a \`Heading\` with no explicit \`size\`), +raw HTML where a brand component exists, hardcoded colors/sizes that should be tokens, +and off-brand visual tells (purple gradients, pill buttons, glassmorphism, placeholder copy). +Run it on everything you wrote and fix what it reports rather than guessing.` export const primerBrandReviewTool: ToolModule = { name: 'primer_brand_review', diff --git a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts index 76130c46d9..79cfac2b9c 100644 --- a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts +++ b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.test.ts @@ -1,7 +1,17 @@ +import {existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + import {makeContext} from '../../test-utils/catalog.js' import {primerBrandSetupTool} from './primer-brand-setup.js' describe('primer_brand_setup', () => { + it('discloses its AGENTS.md side effect without managed-block implementation detail', () => { + expect(primerBrandSetupTool.description).toContain('package that declares `@primer/react-brand`') + expect(primerBrandSetupTool.description).toContain('Primer Brand tools and local docs') + expect(primerBrandSetupTool.description).not.toContain('managed') + }) + it('uses the ESM import path + fonts for Vite, with no use-client boundary or lib css import', async () => { const result = await primerBrandSetupTool.run({framework: 'vite'}, makeContext()) expect(result.text).toContain("from '@primer/react-brand/esm'") @@ -40,4 +50,70 @@ describe('primer_brand_setup', () => { expect(result.text).toContain('Wrap the very root') expect(result.text).toContain('primer_brand_review') }) + + it('creates managed agent instructions and reports subsequent calls as unchanged', async () => { + const projectDir = mkdtempSync(join(tmpdir(), 'primer-brand-setup-')) + writeFileSync( + join(projectDir, 'package.json'), + JSON.stringify({dependencies: {vite: '^7.0.0', '@primer/react-brand': '^0.73.0'}}), + ) + const context = makeContext({ + workspaceDir: projectDir, + framework: {id: 'vite', label: 'Vite + React', rsc: false, projectDir}, + }) + + try { + const created = await primerBrandSetupTool.run({framework: 'auto'}, context) + expect(created.text).toContain('## 7. Add/update AGENTS.md pointer') + expect(created.text).toContain('Created `AGENTS.md` beside the selected package.json') + expect(created.text).toContain('src/main.tsx') + expect(readFileSync(join(projectDir, 'AGENTS.md'), 'utf8')).toContain('`primer_brand_docs`') + + const unchanged = await primerBrandSetupTool.run({framework: 'auto'}, context) + expect(unchanged.text).toContain('already has the current managed Primer Brand instructions') + } finally { + rmSync(projectDir, {recursive: true, force: true}) + } + }) + + it('requires projectDir when several workspace packages declare Primer Brand', async () => { + const workspaceDir = mkdtempSync(join(tmpdir(), 'primer-brand-monorepo-')) + const docsDir = join(workspaceDir, 'apps', 'docs') + const siteDir = join(workspaceDir, 'apps', 'site') + for (const projectDir of [docsDir, siteDir]) { + mkdirSync(projectDir, {recursive: true}) + writeFileSync( + join(projectDir, 'package.json'), + JSON.stringify({dependencies: {'@primer/react-brand': '^0.73.0'}}), + ) + } + const context = makeContext({ + workspaceDir, + framework: {id: 'unknown', label: 'a React project', rsc: false, projectDir: workspaceDir}, + }) + + try { + const ambiguous = await primerBrandSetupTool.run({framework: 'auto'}, context) + expect(ambiguous.text).toContain('multiple package.json files declare `@primer/react-brand`') + expect(ambiguous.text).toContain('`projectDir`') + expect(existsSync(join(docsDir, 'AGENTS.md'))).toBe(false) + expect(existsSync(join(siteDir, 'AGENTS.md'))).toBe(false) + + const selected = await primerBrandSetupTool.run({framework: 'auto', projectDir: 'apps/site'}, context) + expect(selected.text).toContain('Created `AGENTS.md` beside the selected package.json') + expect(existsSync(join(siteDir, 'AGENTS.md'))).toBe(true) + expect(existsSync(join(docsDir, 'AGENTS.md'))).toBe(false) + } finally { + rmSync(workspaceDir, {recursive: true, force: true}) + } + }) + + it('declares its managed workspace update as non-destructive and idempotent', () => { + expect(primerBrandSetupTool.annotations).toEqual({ + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }) + }) }) diff --git a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts index 102928d693..024c29ab05 100644 --- a/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts +++ b/packages/mcp/src/tools/primer-brand-setup/primer-brand-setup.ts @@ -1,6 +1,12 @@ import {z} from 'zod' - -import {detectFramework, type FrameworkId} from '../../brand/detect-framework.js' +import {relative} from 'node:path' + +import {detectFramework, type FrameworkId} from '../../brand/detect-framework/detect-framework.js' +import { + resolveBrandProject, + type BrandProjectResolution, +} from '../../brand/resolve-brand-project/resolve-brand-project.js' +import {updateAgentsMd, type AgentsMdUpdateResult} from '../../brand/update-agents-md/update-agents-md.js' import {versionNote} from '../format.js' import type {ToolContext, ToolModule, ToolResult} from '../types.js' @@ -12,11 +18,20 @@ const inputSchema = z.object({ .optional() .default('auto') .describe('Target framework. "auto" detects it from the project; override if detection is wrong.'), + projectDir: z + .string() + .min(1) + .optional() + .describe( + 'Workspace-relative package folder whose package.json declares @primer/react-brand. Set this when several workspace packages declare it.', + ), }) type Input = z.infer -const description = `Set up the Primer Brand (@primer/react-brand) foundation that agents routinely forget: the ThemeProvider at the app root, the Mona Sans font import, the correct style import, and the \`'use client'\` boundary for React Server Components. Detects the framework (Next App/Pages, Vite, Astro, Remix) and returns tailored, copy-ready setup. Call this once before building a Primer Brand page.` +const description = `Set up Primer Brand in an existing React project. +Call once before building a page; detects or accepts the framework and returns tailored root setup. +Also updates \`AGENTS.md\` in the package that declares \`@primer/react-brand\` so future agents use Primer Brand tools and local docs.` const STATIC: Record = { 'next-app': {label: 'Next.js (App Router)', rsc: true}, @@ -106,7 +121,14 @@ import {ThemeProvider} from '@primer/react-brand/esm' ` -function build(id: FrameworkId, ctx: ToolContext): string { +type SetupAgentsMdResult = { + action: AgentsMdUpdateResult['action'] + path: string | null + reason?: AgentsMdUpdateResult['reason'] | BrandProjectResolution['reason'] + candidates?: string[] +} + +function build(id: FrameworkId, ctx: ToolContext, agentsMd: SetupAgentsMdResult): string { const {label, rsc} = STATIC[id] const snippet = id === 'unknown' ? GENERIC_SNIPPET : ROOT_SNIPPETS[id] @@ -114,6 +136,45 @@ function build(id: FrameworkId, ctx: ToolContext): string { ? `\n\n> **RSC boundary:** \`ThemeProvider\` uses React context, so it must live in a \`'use client'\` component (the \`Providers\` wrapper above). It cannot go directly in the server-rendered \`layout.tsx\`.` : '' + const agentsMdResult = (() => { + switch (agentsMd.action) { + case 'created': + return 'Created `AGENTS.md` beside the selected package.json with local docs routing instructions.' + case 'updated': + return 'Updated the managed Primer Brand instructions beside the selected package.json; existing content was preserved.' + case 'unchanged': + return 'The selected package already has the current managed Primer Brand instructions in `AGENTS.md`.' + case 'skipped': + if (agentsMd.reason === 'ambiguous-brand-project') { + const candidates = agentsMd.candidates + ?.map(candidate => { + const relativeCandidate = ctx.workspaceDir ? relative(ctx.workspaceDir, candidate) : candidate + return relativeCandidate || '.' + }) + .join(', ') + return `Skipped \`AGENTS.md\`: multiple package.json files declare \`@primer/react-brand\`${ + candidates ? ` (${candidates})` : '' + }. Rerun \`primer_brand_setup\` with \`projectDir\` set to the intended package folder.` + } + if (agentsMd.reason === 'invalid-brand-project') { + return 'Skipped `AGENTS.md`: `projectDir` must identify a folder inside the workspace whose package.json declares `@primer/react-brand`.' + } + if (agentsMd.reason === 'brand-package-not-found') { + return 'Skipped `AGENTS.md`: no package.json declaring `@primer/react-brand` was found in the workspace.' + } + if (agentsMd.reason === 'incorrect-filename-case') { + return 'Skipped `AGENTS.md`: rename the existing case-insensitive match to exactly `AGENTS.md`, then rerun setup.' + } + if (agentsMd.reason === 'malformed-managed-block') { + return 'Skipped `AGENTS.md`: the Primer Brand managed markers are malformed or duplicated; repair or remove that block, then rerun setup.' + } + if (agentsMd.reason === 'unsafe-agents-path') { + return 'Skipped `AGENTS.md`: the existing path is a symbolic link or resolves outside the selected package. Replace it with a regular file inside the package, then rerun setup.' + } + return 'Skipped `AGENTS.md`: no project root was detected.' + } + })() + return [ `# Set up Primer Brand — ${label}`, `## 1. Install\n${fence}bash\nnpm install @primer/react-brand\n${fence}`, @@ -122,6 +183,7 @@ function build(id: FrameworkId, ctx: ToolContext): string { `## 4. Styles\nImporting components from \`@primer/react-brand/esm\` auto-includes each component's styles. **Do not also import \`@primer/react-brand/lib/css/main.css\`** — that is the non-ESM path, and mixing the two double-loads styles.`, `## 5. Build the page\n- \`primer_brand_page_design\` first for page-design patterns and the current-brand reference templates to start from\n- \`primer_brand_examples\` for a correct starting composition, then \`primer_brand_component\` for exact props\n- \`primer_brand_tokens\` / \`primer_brand_asset\` for colors, spacing, and icons\n- \`primer_brand_review\` on your complete output — JSX and CSS together — before you finish`, `## 6. Header & footer\nFor a global header use \`SubdomainNavBar\`; for the footer use \`MinimalFooter\`. Don't hand-roll a \`
\`, \`