From 663c09839f7160cacbcd3bc061c15a7150665692 Mon Sep 17 00:00:00 2001 From: Cory Rylan Date: Wed, 1 Jul 2026 16:28:27 -0500 Subject: [PATCH] fix(cli): improve skills validation - Added a well-known endpoint for installing the `elements` skill via skills CLI - Implemented validation for skill names and descriptions to ensure compliance with defined standards. - Updated the `elements` skill metadata to include a title and license in the generated markdown. - Refactored skill markdown formatting to ensure proper structure and compliance with new requirements. - Introduced tests for validating skill entries and ensuring correct markdown formatting. - Removed deprecated markdown utility functions and updated imports accordingly. This update improves the usability and reliability of the Agent Skills feature in the CLI. Signed-off-by: Cory Rylan --- projects/cli/README.md | 8 + .../internals/tools/src/skills/index.test.ts | 25 ++- projects/internals/tools/src/skills/index.ts | 2 +- .../internals/tools/src/skills/markdown.ts | 19 --- .../internals/tools/src/skills/registry.ts | 2 +- .../tools/src/skills/service.test.ts | 8 +- .../internals/tools/src/skills/service.ts | 2 +- .../internals/tools/src/skills/utils.test.ts | 151 ++++++++++++++++++ projects/internals/tools/src/skills/utils.ts | 130 +++++++++++++++ projects/site/eleventy.config.js | 2 + projects/site/package.json | 2 +- .../site/src/_11ty/plugins/agent-skills.js | 14 ++ .../src/_11ty/plugins/agent-skills.test.ts | 61 +++++++ projects/site/src/_11ty/plugins/llms-txt.js | 5 +- .../site/src/_11ty/plugins/llms-txt.test.ts | 3 +- .../site/src/_11ty/utils/public-output.js | 5 + projects/site/src/docs/skills/index.md | 28 +++- 17 files changed, 431 insertions(+), 36 deletions(-) delete mode 100644 projects/internals/tools/src/skills/markdown.ts create mode 100644 projects/internals/tools/src/skills/utils.test.ts create mode 100644 projects/internals/tools/src/skills/utils.ts create mode 100644 projects/site/src/_11ty/plugins/agent-skills.js create mode 100644 projects/site/src/_11ty/plugins/agent-skills.test.ts create mode 100644 projects/site/src/_11ty/utils/public-output.js diff --git a/projects/cli/README.md b/projects/cli/README.md index b1c59db4d..f3fddbaff 100644 --- a/projects/cli/README.md +++ b/projects/cli/README.md @@ -163,6 +163,14 @@ Skills provide persistent context to AI agents for building UI with Elements. Run `nve skills.list` or call MCP `skills_list` for the authoritative list. Deployments with the playground service enabled can also expose a `playground` skill for creating Elements Playground prototypes. +The Agent Skills well-known endpoint publishes the `elements` skill from the same registry for skill-only installation with the open `skills` CLI: + +```shell +npx skills add https://nvidia.github.io/elements +``` + +This hosted route does not install the Elements CLI or configure the MCP server. Use `nve project.setup` for complete project setup, and continue to use the CLI or MCP tools for deterministic API lookup and template validation. Other registry skills, including a conditional `playground` skill, remain available through `nve` rather than the hosted endpoint. + ### Tools | Tool | Description | diff --git a/projects/internals/tools/src/skills/index.test.ts b/projects/internals/tools/src/skills/index.test.ts index f5feac198..eb92242b2 100644 --- a/projects/internals/tools/src/skills/index.test.ts +++ b/projects/internals/tools/src/skills/index.test.ts @@ -147,6 +147,15 @@ describe('skillEntries', () => { expect(uniqueNames.size).toBe(names.length); }); + it('should have valid Agent Skills names and descriptions', () => { + skills.forEach(skill => { + expect(skill.name).toMatch(/^[a-z0-9]+(?:-[a-z0-9]+)*$/); + expect(skill.name.length).toBeLessThanOrEqual(64); + expect(skill.description.length).toBeGreaterThanOrEqual(1); + expect(skill.description.length).toBeLessThanOrEqual(1024); + }); + }); + it('should include authoring, artifact, and elements entries', () => { expect(skills.some(skill => skill.name === 'authoring')).toBe(true); expect(skills.some(skill => skill.name === 'artifact')).toBe(true); @@ -170,9 +179,21 @@ describe('skillEntries', () => { const markdown = formatSkillMarkdown(elementsSkill); - expect(markdown).toMatch(/^---\nname: "elements"\ntitle: "Elements Design System \(nve\)"/); - expect(markdown).toContain('description: "Use this skill by default'); + expect(markdown).toMatch(/^---\nname: "elements"\ndescription: "Use this skill by default/); + expect(markdown).toContain('\nlicense: "Apache-2.0"\n'); + expect(markdown).toContain('\nmetadata:\n title: "NVIDIA Elements Design System \(nve\)"\n'); + expect(markdown).not.toMatch(/^title:/m); expect(markdown).toContain('# Building UI with NVIDIA Elements'); expect(markdown.endsWith('\n')).toBe(true); + expect(markdown.endsWith('\n\n')).toBe(false); + }); + + it('should terminate every formatted skill with one newline', () => { + skills.forEach(skill => { + const markdown = formatSkillMarkdown(skill); + + expect(markdown.endsWith('\n')).toBe(true); + expect(markdown.endsWith('\n\n')).toBe(false); + }); }); }); diff --git a/projects/internals/tools/src/skills/index.ts b/projects/internals/tools/src/skills/index.ts index 64c0ecb6e..702fc66d2 100644 --- a/projects/internals/tools/src/skills/index.ts +++ b/projects/internals/tools/src/skills/index.ts @@ -2,5 +2,5 @@ // SPDX-License-Identifier: Apache-2.0 export * from './registry.js'; -export * from './markdown.js'; export * from './service.js'; +export * from './utils.js'; diff --git a/projects/internals/tools/src/skills/markdown.ts b/projects/internals/tools/src/skills/markdown.ts deleted file mode 100644 index 5e2951df3..000000000 --- a/projects/internals/tools/src/skills/markdown.ts +++ /dev/null @@ -1,19 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import type { Skill } from './registry.js'; - -function formatYamlString(value: string): string { - return JSON.stringify(value); -} - -export function formatSkillMarkdown(skill: Skill): string { - return `--- -name: ${formatYamlString(skill.name)} -title: ${formatYamlString(skill.title)} -description: ${formatYamlString(skill.description)} ---- - -${skill.context.trim()} -`; -} diff --git a/projects/internals/tools/src/skills/registry.ts b/projects/internals/tools/src/skills/registry.ts index 3d050ac14..fbe77fde1 100644 --- a/projects/internals/tools/src/skills/registry.ts +++ b/projects/internals/tools/src/skills/registry.ts @@ -206,7 +206,7 @@ const migrateProjectPrompt: Prompt = { const elementsSkill: Skill = { name: 'elements', - title: 'Elements Design System (nve)', + title: 'NVIDIA Elements Design System (nve)', description: 'Use this skill by default for any UI-related work or with NVIDIA Elements (nve-*), including creating, editing, reviewing, or debugging HTML, CSS, layout, theming, components, applications, prototypes, Claude Artifacts, Codex Sites pages, and standalone UI artifacts.', context: ` diff --git a/projects/internals/tools/src/skills/service.test.ts b/projects/internals/tools/src/skills/service.test.ts index 1683d8e65..5b3112623 100644 --- a/projects/internals/tools/src/skills/service.test.ts +++ b/projects/internals/tools/src/skills/service.test.ts @@ -29,11 +29,16 @@ describe('SkillsService', () => { expect(result).toContain( `--- name: "authoring" -title: "NVIDIA Elements Authoring Guidelines" description: "Best practices and workflow guidance for authoring UI with NVIDIA Elements." +license: "Apache-2.0" +metadata: + title: "NVIDIA Elements Authoring Guidelines" ---` ); expect(result).toMatch(/^---\nname: "authoring"/); + expect(result).not.toMatch(/^title:/m); + expect(result.endsWith('\n')).toBe(true); + expect(result.endsWith('\n\n')).toBe(false); expect(result).toContain('## Authoring Guidelines'); expect((SkillsService.get as ToolMethod).metadata.name).toBe('get'); expect((SkillsService.get as ToolMethod).metadata.command).toBe('get'); @@ -43,6 +48,7 @@ description: "Best practices and workflow guidance for authoring UI with NVIDIA it('should get a skill context as json', async () => { const result = (await SkillsService.get({ name: 'elements', format: 'json' })) as Skill; expect(result.name).toBe('elements'); + expect(result.title).toBe('NVIDIA Elements Design System (nve)'); expect(result.context).toContain('Building UI with NVIDIA Elements'); }); diff --git a/projects/internals/tools/src/skills/service.ts b/projects/internals/tools/src/skills/service.ts index c0e94300c..4635487b8 100644 --- a/projects/internals/tools/src/skills/service.ts +++ b/projects/internals/tools/src/skills/service.ts @@ -4,7 +4,7 @@ import { service, tool } from '../internal/tools.js'; import { markdownDescription } from '../internal/utils.js'; import { skills, type Skill } from './registry.js'; -import { formatSkillMarkdown } from './markdown.js'; +import { formatSkillMarkdown } from './utils.js'; type OutputFormat = 'markdown' | 'json'; diff --git a/projects/internals/tools/src/skills/utils.test.ts b/projects/internals/tools/src/skills/utils.test.ts new file mode 100644 index 000000000..8cfced68b --- /dev/null +++ b/projects/internals/tools/src/skills/utils.test.ts @@ -0,0 +1,151 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import { tmpdir } from 'node:os'; +import nodePath from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { skills, type Skill } from './registry.js'; +import { + AGENT_SKILLS_DISCOVERY_SCHEMA, + createAgentSkillArtifacts, + validateSkillDescription, + validateSkillName, + writeAgentSkillArtifacts +} from './utils.js'; + +const temporaryDirectories: string[] = []; + +function createSkill(name: string, overrides: Partial = {}): Skill { + return { + name, + title: `${name} title`, + description: `${name} description`, + context: `# ${name}\n\n${name} context`, + ...overrides + }; +} + +async function createTemporaryDirectory() { + const directory = await fsp.mkdtemp(nodePath.join(tmpdir(), 'elements-agent-skills-')); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(directory => fsp.rm(directory, { recursive: true, force: true })) + ); +}); + +describe('validateSkillName', () => { + it.each(['a', 'elements', 'skill-1', 'a'.repeat(64)])('should accept valid name %s', name => { + expect(() => validateSkillName(name)).not.toThrow(); + }); + + it.each([undefined, null, 1, '', 'Invalid', '-invalid', 'invalid-', 'invalid--name', 'invalid_name', 'a'.repeat(65)])( + 'should reject invalid name %s', + name => { + expect(() => validateSkillName(name)).toThrow(/Invalid Agent Skill name/); + } + ); +}); + +describe('validateSkillDescription', () => { + it.each(['a', 'a'.repeat(1024)])('should accept a description between 1 and 1,024 characters', description => { + expect(() => validateSkillDescription('elements', description)).not.toThrow(); + }); + + it.each([undefined, null, 1, '', ' ', 'a'.repeat(1025)])('should reject invalid description %s', description => { + expect(() => validateSkillDescription('elements', description)).toThrow( + /Invalid Agent Skill description for "elements"/ + ); + }); +}); + +describe('createAgentSkillArtifacts', () => { + it('should create deterministic discovery 0.2 entries', () => { + const registry = [createSkill('zeta'), createSkill('alpha')]; + const artifacts = createAgentSkillArtifacts(registry); + + expect(artifacts.index.$schema).toBe(AGENT_SKILLS_DISCOVERY_SCHEMA); + expect(artifacts.index.skills.map(skill => skill.name)).toEqual(['alpha', 'zeta']); + expect(artifacts.index.skills.every(skill => skill.type === 'skill-md')).toBe(true); + expect(artifacts.index.skills.map(skill => skill.url)).toEqual(['alpha/SKILL.md', 'zeta/SKILL.md']); + expect(createAgentSkillArtifacts([...registry].reverse())).toEqual(artifacts); + }); + + it('should hash the exact generated Markdown bytes', () => { + const artifacts = createAgentSkillArtifacts(); + + expect(artifacts.index.skills.map(entry => entry.name)).toEqual(skills.map(skill => skill.name).sort()); + artifacts.index.skills.forEach(entry => { + const markdown = artifacts.files.get(entry.url); + expect(markdown).toBeDefined(); + if (!markdown) return; + expect(entry.digest).toBe(`sha256:${createHash('sha256').update(Buffer.from(markdown, 'utf8')).digest('hex')}`); + }); + }); + + it('should generate standard frontmatter with the registry context', () => { + const elements = skills.find(skill => skill.name === 'elements'); + expect(elements).toBeDefined(); + if (!elements) return; + + const artifacts = createAgentSkillArtifacts([elements]); + const markdown = artifacts.files.get('elements/SKILL.md'); + + expect(markdown).toMatch( + /^---\nname: "elements"\ndescription: ".+"\nlicense: "Apache-2.0"\nmetadata:\n title: "NVIDIA Elements Design System \(nve\)"\n---\n/ + ); + expect(markdown).not.toMatch(/^title:/m); + expect(markdown).toContain(elements.context.trim()); + expect(markdown?.endsWith('\n')).toBe(true); + expect(markdown?.endsWith('\n\n')).toBe(false); + }); + + it('should publish conditional skills supplied by the registry', () => { + const artifacts = createAgentSkillArtifacts([createSkill('playground')]); + + expect(artifacts.index.skills.map(skill => skill.name)).toEqual(['playground']); + expect(artifacts.files.has('playground/SKILL.md')).toBe(true); + }); + + it('should reject duplicate skill names', () => { + expect(() => createAgentSkillArtifacts([createSkill('duplicate'), createSkill('duplicate')])).toThrow( + 'Duplicate Agent Skill name "duplicate".' + ); + }); +}); + +describe('writeAgentSkillArtifacts', () => { + it('should write the index and skill directory tree', async () => { + const publicOutputPath = await createTemporaryDirectory(); + await writeAgentSkillArtifacts(publicOutputPath, [createSkill('alpha'), createSkill('beta')]); + const outputPath = nodePath.join(publicOutputPath, '.well-known', 'agent-skills'); + + const index = JSON.parse(await fsp.readFile(nodePath.join(outputPath, 'index.json'), 'utf8')); + expect(index.skills).toEqual([ + expect.objectContaining({ name: 'alpha' }), + expect.objectContaining({ name: 'beta' }) + ]); + await expect(fsp.readFile(nodePath.join(outputPath, 'alpha', 'SKILL.md'), 'utf8')).resolves.toContain( + 'name: "alpha"' + ); + await expect(fsp.readFile(nodePath.join(outputPath, 'beta', 'SKILL.md'), 'utf8')).resolves.toContain( + 'name: "beta"' + ); + expect((await fsp.readFile(nodePath.join(outputPath, 'index.json'), 'utf8')).endsWith('\n')).toBe(true); + }); + + it('should remove stale skills before writing', async () => { + const publicOutputPath = await createTemporaryDirectory(); + const outputPath = nodePath.join(publicOutputPath, '.well-known', 'agent-skills'); + await writeAgentSkillArtifacts(publicOutputPath, [createSkill('current'), createSkill('stale')]); + await writeAgentSkillArtifacts(publicOutputPath, [createSkill('current')]); + + await expect(fsp.stat(nodePath.join(outputPath, 'stale'))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(fsp.readFile(nodePath.join(outputPath, 'current', 'SKILL.md'), 'utf8')).resolves.toBeDefined(); + }); +}); diff --git a/projects/internals/tools/src/skills/utils.ts b/projects/internals/tools/src/skills/utils.ts new file mode 100644 index 000000000..faf29cbdc --- /dev/null +++ b/projects/internals/tools/src/skills/utils.ts @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { createHash } from 'node:crypto'; +import { promises as fsp } from 'node:fs'; +import nodePath from 'node:path'; +import { skills, type Skill } from './registry.js'; + +export const AGENT_SKILLS_DISCOVERY_SCHEMA = 'https://schemas.agentskills.io/discovery/0.2.0/schema.json' as const; + +const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const MAX_SKILL_NAME_LENGTH = 64; +const MAX_SKILL_DESCRIPTION_LENGTH = 1024; + +export interface AgentSkillDiscoveryEntry { + name: string; + type: 'skill-md'; + description: string; + url: string; + digest: string; +} + +export interface AgentSkillDiscoveryIndex { + $schema: typeof AGENT_SKILLS_DISCOVERY_SCHEMA; + skills: AgentSkillDiscoveryEntry[]; +} + +export interface AgentSkillArtifacts { + index: AgentSkillDiscoveryIndex; + files: Map; +} + +function formatYamlString(value: string): string { + return JSON.stringify(value); +} + +export function formatSkillMarkdown(skill: Skill): string { + return `--- +name: ${formatYamlString(skill.name)} +description: ${formatYamlString(skill.description)} +license: "Apache-2.0" +metadata: + title: ${formatYamlString(skill.title)} +--- + +${skill.context.trim()} +`; +} + +export function validateSkillName(name: unknown): asserts name is string { + if (typeof name !== 'string' || name.length > MAX_SKILL_NAME_LENGTH || !SKILL_NAME_PATTERN.test(name)) { + throw new Error( + `Invalid Agent Skill name ${JSON.stringify(name)}: use 1 to 64 lowercase letters, numbers, and single hyphens.` + ); + } +} + +export function validateSkillDescription(name: string, description: unknown): asserts description is string { + if ( + typeof description !== 'string' || + description.trim().length === 0 || + description.length > MAX_SKILL_DESCRIPTION_LENGTH + ) { + throw new Error(`Invalid Agent Skill description for ${JSON.stringify(name)}: use 1 to 1,024 characters.`); + } +} + +export function validateSkillEntries(skillEntries: readonly Skill[] = skills): void { + const names = new Set(); + + for (const skill of skillEntries) { + const name = skill?.name; + validateSkillName(name); + if (names.has(name)) { + throw new Error(`Duplicate Agent Skill name ${JSON.stringify(name)}.`); + } + names.add(name); + validateSkillDescription(name, skill.description); + } +} + +export function createAgentSkillArtifacts(skillEntries: readonly Skill[] = skills): AgentSkillArtifacts { + validateSkillEntries(skillEntries); + + const files = new Map(); + const entries: AgentSkillDiscoveryEntry[] = [...skillEntries] + .sort((a, b) => a.name.localeCompare(b.name)) + .map(skill => { + const markdown = formatSkillMarkdown(skill); + const url = `${skill.name}/SKILL.md`; + files.set(url, markdown); + + return { + name: skill.name, + type: 'skill-md', + description: skill.description, + url, + digest: `sha256:${createHash('sha256').update(Buffer.from(markdown, 'utf8')).digest('hex')}` + }; + }); + + return { + index: { + $schema: AGENT_SKILLS_DISCOVERY_SCHEMA, + skills: entries + }, + files + }; +} + +export async function writeAgentSkillArtifacts( + publicOutputPath: string, + skillEntries: readonly Skill[] = skills +): Promise { + const outputPath = nodePath.resolve(publicOutputPath, '.well-known', 'agent-skills'); + const artifacts = createAgentSkillArtifacts(skillEntries); + + await fsp.rm(outputPath, { recursive: true, force: true }); + await fsp.mkdir(outputPath, { recursive: true }); + + for (const [relativePath, content] of artifacts.files) { + const filePath = nodePath.join(outputPath, relativePath); + await fsp.mkdir(nodePath.dirname(filePath), { recursive: true }); + await fsp.writeFile(filePath, content, 'utf8'); + } + + await fsp.writeFile(nodePath.join(outputPath, 'index.json'), `${JSON.stringify(artifacts.index, null, 2)}\n`, 'utf8'); + + return artifacts; +} diff --git a/projects/site/eleventy.config.js b/projects/site/eleventy.config.js index 419328c3c..3045f7534 100644 --- a/projects/site/eleventy.config.js +++ b/projects/site/eleventy.config.js @@ -15,6 +15,7 @@ import { ELEMENTS_ASSETS_CDN_BASE_URL } from './src/_11ty/utils/env.js'; import { searchPlugin } from './src/_11ty/plugins/search.js'; +import { agentSkillsPlugin } from './src/_11ty/plugins/agent-skills.js'; import { llmsTxtPlugin } from './src/_11ty/plugins/llms-txt.js'; import { sitemapPlugin } from './src/_11ty/plugins/sitemap-xml.js'; import { elementLoaderTransform } from './src/_11ty/transforms/element-loader.js'; @@ -168,6 +169,7 @@ export default function (eleventyConfig) { // https://llmstxt.org if (process.env.ELEVENTY_RUN_MODE === 'build') { eleventyConfig.addPlugin(llmsTxtPlugin); + eleventyConfig.addPlugin(agentSkillsPlugin); } // https://www.sitemaps.org diff --git a/projects/site/package.json b/projects/site/package.json index ad8269ee9..f93cbd648 100644 --- a/projects/site/package.json +++ b/projects/site/package.json @@ -201,7 +201,7 @@ ] }, "test": { - "command": "vitest run src/_11ty/layouts/metadata.test.ts src/_11ty/layouts/links.test.ts src/_11ty/transforms/site-urls.test.ts src/_11ty/shortcodes/api.test.ts src/_11ty/shortcodes/example.test.ts src/_11ty/plugins/llms-txt.test.ts src/_11ty/plugins/sitemap-xml.test.ts src/_11ty/utils/env.test.ts src/docs/metrics/api-status.test.ts src/examples/index.test.ts src/docs/metrics/adoption-data.test.ts src/docs/metrics/release-data.test.ts", + "command": "vitest run", "files": [ "src/**/*.js", "src/**/*.md", diff --git a/projects/site/src/_11ty/plugins/agent-skills.js b/projects/site/src/_11ty/plugins/agent-skills.js new file mode 100644 index 000000000..6b0765824 --- /dev/null +++ b/projects/site/src/_11ty/plugins/agent-skills.js @@ -0,0 +1,14 @@ +import { skills, writeAgentSkillArtifacts } from '@internals/tools/skills'; +import { getPublicOutputPath } from '../utils/public-output.js'; + +const publishedSkills = skills.filter(skill => skill.name === 'elements'); + +if (publishedSkills.length !== 1) { + throw new Error('Expected exactly one "elements" skill in the registry.'); +} + +export function agentSkillsPlugin(eleventyConfig) { + eleventyConfig.on('eleventy.before', async ({ directories } = {}) => { + await writeAgentSkillArtifacts(getPublicOutputPath(directories), publishedSkills); + }); +} diff --git a/projects/site/src/_11ty/plugins/agent-skills.test.ts b/projects/site/src/_11ty/plugins/agent-skills.test.ts new file mode 100644 index 000000000..bf90b0762 --- /dev/null +++ b/projects/site/src/_11ty/plugins/agent-skills.test.ts @@ -0,0 +1,61 @@ +import { promises as fsp } from 'node:fs'; +import { tmpdir } from 'node:os'; +import nodePath from 'node:path'; +import { AGENT_SKILLS_DISCOVERY_SCHEMA } from '@internals/tools/skills'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { agentSkillsPlugin } from './agent-skills.js'; + +const temporaryDirectories: string[] = []; + +async function createTemporaryDirectory() { + const directory = await fsp.mkdtemp(nodePath.join(tmpdir(), 'elements-agent-skills-')); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + await Promise.all( + temporaryDirectories.splice(0).map(directory => fsp.rm(directory, { recursive: true, force: true })) + ); +}); + +describe('agentSkillsPlugin', () => { + it('should register an Eleventy before handler', () => { + const on = vi.fn(); + + agentSkillsPlugin({ on }); + + expect(on).toHaveBeenCalledWith('eleventy.before', expect.any(Function)); + }); + + it('should write under the Eleventy public output directory', async () => { + const root = await createTemporaryDirectory(); + const output = nodePath.join(root, 'dist'); + const on = vi.fn(); + agentSkillsPlugin({ on }); + const callback = on.mock.calls[0][1]; + + await callback({ directories: { output } }); + + const outputPath = nodePath.join(output, 'public', '.well-known', 'agent-skills'); + const index = JSON.parse(await fsp.readFile(nodePath.join(outputPath, 'index.json'), 'utf8')); + expect(index).toEqual({ + $schema: AGENT_SKILLS_DISCOVERY_SCHEMA, + skills: [expect.objectContaining({ name: 'elements' })] + }); + await expect(fsp.stat(nodePath.join(outputPath, 'about'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); + +describe('production site build', () => { + it('should preserve only the elements skill in dist', async () => { + const outputPath = 'dist/.well-known/agent-skills'; + const index = JSON.parse(await fsp.readFile(nodePath.join(outputPath, 'index.json'), 'utf8')); + + expect(index.skills).toEqual([expect.objectContaining({ name: 'elements' })]); + await expect(fsp.readFile(nodePath.join(outputPath, 'elements', 'SKILL.md'), 'utf8')).resolves.toContain( + 'name: "elements"' + ); + await expect(fsp.stat(nodePath.join(outputPath, 'about'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); +}); diff --git a/projects/site/src/_11ty/plugins/llms-txt.js b/projects/site/src/_11ty/plugins/llms-txt.js index 140650d21..7cfd0e074 100644 --- a/projects/site/src/_11ty/plugins/llms-txt.js +++ b/projects/site/src/_11ty/plugins/llms-txt.js @@ -6,6 +6,7 @@ import { ExamplesService } from '@internals/tools/examples'; import { skills } from '@internals/tools/skills'; import { DEPLOYED_SITE_URL, getSiteUrl } from '../utils/site-url.js'; import { siteUrlsTransform } from '../transforms/site-urls.js'; +import { getPublicOutputPath } from '../utils/public-output.js'; const BASE = DEPLOYED_SITE_URL; const DEFAULT_PUBLIC_OUTPUT_PATH = './.11ty-vite/public'; @@ -36,10 +37,6 @@ function getMarkdownMeta(markdown) { }; } -export function getPublicOutputPath(directories = {}) { - return nodePath.join(directories.output ?? 'dist', 'public'); -} - export function getContextUrl(filePath, extension, publicOutputPath = DEFAULT_PUBLIC_OUTPUT_PATH) { return getSiteUrl(`/${nodePath.relative(publicOutputPath, filePath).split(nodePath.sep).join('/')}${extension}`); } diff --git a/projects/site/src/_11ty/plugins/llms-txt.test.ts b/projects/site/src/_11ty/plugins/llms-txt.test.ts index 158d95900..66b45b5b5 100644 --- a/projects/site/src/_11ty/plugins/llms-txt.test.ts +++ b/projects/site/src/_11ty/plugins/llms-txt.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createLlmsTxtContent, getPublicOutputPath, llmsTxtPlugin } from './llms-txt.js'; +import { createLlmsTxtContent, llmsTxtPlugin } from './llms-txt.js'; +import { getPublicOutputPath } from '../utils/public-output.js'; async function importLlmsTxt() { vi.resetModules(); diff --git a/projects/site/src/_11ty/utils/public-output.js b/projects/site/src/_11ty/utils/public-output.js new file mode 100644 index 000000000..5490291ac --- /dev/null +++ b/projects/site/src/_11ty/utils/public-output.js @@ -0,0 +1,5 @@ +import nodePath from 'node:path'; + +export function getPublicOutputPath(directories = {}) { + return nodePath.join(directories.output ?? 'dist', 'public'); +} diff --git a/projects/site/src/docs/skills/index.md b/projects/site/src/docs/skills/index.md index 5c5fe6f03..6012e1c79 100644 --- a/projects/site/src/docs/skills/index.md +++ b/projects/site/src/docs/skills/index.md @@ -16,7 +16,7 @@ The Model Context Protocol standardizes tools, prompts, and resources. Elements {% install-cli %} -## Add Skills To A Project +## Add Skills to an Existing Project The recommended path is the project setup command. Run it from the project root: @@ -34,6 +34,22 @@ The setup command configures Elements for common agent clients and editor toolin New starter projects created with `nve project.create` receive the same agent setup during project creation. +## Install Skills Only + +Elements publishes the `elements` skill through an Agent Skills well-known endpoint. The hosted file comes from the same registry as `nve skills.list`. Inspect or install it with the open [skills](https://www.skills.sh/) CLI: + +```shell +npx skills add https://nvidia.github.io/elements +``` + +Target Codex and Claude Code explicitly when you don't want automatic agent detection: + +```shell +npx skills add https://nvidia.github.io/elements --agent codex --agent claude-code +``` + +This route installs skill files only. It does not install the Elements CLI, configure the MCP server, add editor data, or add package dependencies. Continue to use CLI or MCP API lookup and template validation for deterministic, current project data. + ## Manual Skill Setup Use the CLI when you need to inspect or install a skill by hand: @@ -47,9 +63,11 @@ Place the selected skill content in the directory format supported by your agent ```html --- -name: elements -title: Elements Design System (nve) -description: Use this skill by default for any UI-related work or with NVIDIA Elements (nve-*), including creating, editing, reviewing, or debugging HTML, CSS, layout, theming, components, applications, prototypes, Claude Artifacts, Codex Sites pages, and standalone UI artifacts. +name: "elements" +description: "Use this skill by default for any UI-related work or with NVIDIA Elements (nve-*), including creating, editing, reviewing, or debugging HTML, CSS, layout, theming, components, applications, prototypes, Claude Artifacts, Codex Sites pages, and standalone UI artifacts." +license: "Apache-2.0" +metadata: + title: "Elements Design System (nve)" --- # Building UI with NVIDIA Elements @@ -121,7 +139,7 @@ The default `nve skills.list` command and MCP `skills_list` tool expose these bu -The `playground` skill is available when the CLI or MCP server has the Elements playground service enabled. +The `playground` skill is available when the CLI, MCP server, or hosted build has the Elements playground service enabled. ## Use Skills With MCP