Skip to content

Commit 120c0cb

Browse files
mahmoudmoravejclaudealexasselin008
authored
feat(docs): publish Hopper as an agent Skill (#980)
Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Alexandre Asselin <alexandre.asselin@workleap.com>
1 parent 84efed7 commit 120c0cb

40 files changed

Lines changed: 2751 additions & 102 deletions

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ jobs:
5050
- name: Build AI docs and MCP server
5151
run: pnpm build:mcp
5252

53+
- name: Build the agent Skill
54+
run: pnpm build:skills
55+
5356
- name: Lint
5457
run: pnpm lint
5558

AGENTS.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,12 @@ Path patterns: packages/components/src/**/*.tsx
6767

6868
## Skills
6969

70+
Hopper also publishes a consumer-facing agent Skill generated from `apps/docs` — see
71+
`apps/docs/ai-pipeline/CONTRIBUTING.md` and [ADR 0002](docs/adr/0002-hopper-agent-skill.md).
72+
Component and token content edits now propagate to three surfaces: the documentation site, the MCP
73+
server, and that published Skill. It is a build artifact and is not checked in, so it does not
74+
appear in the table below, which lists the skills used when working _in_ this repository.
75+
7076
| Skill | When to use |
7177
| --------------------- | -------------------------------------------------------------------------- |
7278
| `update-tokens` | Add, update, delete, or deprecate design tokens |

apps/docs/.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,3 +41,6 @@ next-env.d.ts
4141

4242
#AI docs
4343
/public/ai-docs
44+
45+
#Generated agent skills, served at /.well-known/skills
46+
/public/agent-skills

apps/docs/ai-pipeline/CONTRIBUTING.md

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Contributing to AI Documentation Configuration
22

3-
This guide explains how to configure the AI documentation system by adding new routes in the `config.ts` file.
3+
This guide explains how to configure the AI documentation system by adding new routes in the `ai-docs.config.tsx` file.
44

55
## Overview
66

@@ -11,7 +11,7 @@ The AI documentation system uses a bidirectional mapping configuration that serv
1111

1212
## Configuration Structure
1313

14-
The main configuration is in `config.ts` and follows this structure:
14+
The main configuration is in `ai-docs.config.tsx` and follows this structure:
1515

1616
```typescript
1717
export const aiDocsConfig: AiDocsConfig = {
@@ -398,3 +398,139 @@ The transformation ensures that:
398398
2. System reverse-lookups the configuration
399399
3. Finds matching route with `serve.at` or route key (through the [txt](/apps/docs/app/txt/) router)
400400
4. Serves the corresponding generated content
401+
402+
## Skills
403+
404+
On top of the AI docs, this pipeline publishes an [agent Skill](https://www.npmjs.com/package/skills)
405+
at `https://hopper.workleap.design/.well-known/skills`, installed with
406+
`npx skills add https://hopper.workleap.design`.
407+
408+
Skill generation is a **second stage that composes `dist/ai-docs`** — it never renders MDX again.
409+
`docs#build:skills` therefore declares `docs#build:ai-docs` as a Turbo dependency, so one command
410+
is enough:
411+
412+
```bash
413+
pnpm build:skills # ~1s once the AI docs are cached; re-run freely
414+
```
415+
416+
`docs#build` also depends on `docs#build:skills`, which is what makes the single
417+
`turbo run build --filter=./apps/docs` command in `netlify.toml` publish the skill.
418+
419+
Output lands in `dist/skills` and is copied to `public/agent-skills` (gitignored, like
420+
`public/ai-docs`).
421+
422+
### Files
423+
424+
| File | Role |
425+
| ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
426+
| `skills.config.ts` | What the skill contains. The only file most changes touch. |
427+
| `skillsTypes.ts` | Entry types and their guards. |
428+
| `templates/skills/hopper/**` | Hand-authored markdown: `SKILL.header.md`, workflows, and the guides that have no generated equivalent. |
429+
| `skill-scripts/**` | Sources for the scripts bundled into the skill. |
430+
| `../scripts/buildSkills.ts` | Orchestrator. |
431+
| `../scripts/ai-utils/{copySkillFiles,generateSkillIndex,generateSkillManifest,bundleSkillScripts}.ts` | The stages it runs. |
432+
| `../scripts/checkSkillsConfig.ts` | Runs as `pnpm --filter=docs test`; see below. |
433+
434+
### Entry types
435+
436+
`skills.config.ts` `files` accepts three shapes, distinguished by which key is present:
437+
438+
```ts
439+
// copy — `from` is a glob under dist/ai-docs. A `to` ending in "/" keeps the source path
440+
// relative to the glob's literal prefix, so nested matches do not collapse together.
441+
{ from: "/components/usage/*.md", exclude: ["/components/usage/component-list.md"], to: "references/components/" }
442+
443+
// copyTemplate — a hand-authored file, copied verbatim
444+
{ copyTemplate: "/ai-pipeline/templates/skills/hopper/workflows/build-app.md", to: "references/workflows/build-app.md" }
445+
446+
// merge — a hand-authored template followed by generated content
447+
{ template: "/ai-pipeline/templates/skills/hopper/guides/validation-rules.md",
448+
merge: ["/styled-system/escape-hatches.md"], to: "references/guides/validation-rules.md" }
449+
```
450+
451+
`SKILL.md` is `templates/skills/hopper/SKILL.header.md` plus a generated "Documentation structure"
452+
index built from what actually landed on disk. Section descriptions come from each file's first
453+
paragraph, or from `description` in the config. Use `style: "names"` for long tails such as
454+
components — a bullet per file would triple the size of `SKILL.md`.
455+
456+
### Scripts share code with the MCP server
457+
458+
The skill's three scripts are **not** reimplementations. Each bundles a service straight out of
459+
`apps/mcp-server` with esbuild, so a logic change there reaches both surfaces at the next build:
460+
461+
| Script | MCP tool | Bundled from |
462+
| ---------------------------------- | ---------------------- | ------------------------------- |
463+
| `scripts/validate-hopper-code.mjs` | `validate_hopper_code` | `src/services/validatorService` |
464+
| `scripts/search-tokens.mjs` | `get_design_tokens` | `src/services/tokensService` |
465+
| `scripts/search-icons.mjs` | `get_icons` | `src/services/iconsService` |
466+
467+
The entry points in `skill-scripts/` are thin CLI wrappers — argument parsing and output
468+
formatting only. Keep them that way; anything resembling business logic belongs in the service, so
469+
both surfaces get it.
470+
471+
Two things are swapped at bundle time, via `aliases` in `skills.config.ts`:
472+
473+
- `@docs/ai` and the `env` module (`dataAliases`) → the services read the skill's own `references/`
474+
instead of a deployed docs folder. This is why the skill mirrors the AI docs layout there.
475+
- `@typescript-eslint/parser``validatorParser.ts`, only for the validator. Bundling the real
476+
parser would pull in `typescript`, several times the whole skill budget.
477+
478+
To verify parity after touching a service, run the script and the matching MCP tool on the same
479+
input and diff them.
480+
481+
### The skill ships as one archive
482+
483+
`buildSkills.ts` writes both the loose files under `dist/skills/hopper/**` _and_ a single
484+
`dist/skills/hopper.tar.gz`, and `index.json` advertises only the archive, using the 0.2.0
485+
discovery schema with a sha256 digest.
486+
487+
That is deliberate. The `skills` CLI's per-file path (`fetchLegacySkillByEntry`) fires one `fetch`
488+
per advertised file with **no concurrency limit** and swallows every failure — `catch {}` returns
489+
`null` and the file is silently skipped, with no error and no retry. At this skill's size that
490+
reliably loses files: measured 263-317 of 326 across runs, with `scripts/` missing every time
491+
because it sorts last and holds the largest files. Nothing in the output tells you.
492+
493+
With the archive the client does one request and verifies a digest, so an install is either
494+
complete or a visible failure. It is also ~10x less to transfer (0.52 MB compressed vs 4.8 MB).
495+
496+
The loose files stay published because they are handy to `curl` and cost nothing extra. If you
497+
change what the skill contains, the archive and digest regenerate automatically — never hand-edit
498+
`index.json`.
499+
500+
### Constraints to respect
501+
502+
- **Exactly one skill.** The `skills` CLI demands an explicit `@selector` when a host advertises
503+
more than one, which would break the bare `npx skills add https://hopper.workleap.design`.
504+
- **A 5.5 MB budget** (`maxTotalBytes`). The build fails above it. Trim an entry rather than raising
505+
the ceiling. The heavy things deliberately left out are `components/api/full` (7 MB),
506+
`llms-full.md`, every `*/index.md` merge artifact, and `changelogs.md`.
507+
- **`references/tokens/maps/**`, `references/icons/data.json` and
508+
`references/styled-system/unsafe-props-data.json` must keep their AI docs paths.** The bundled
509+
scripts reach them through the generated `files` index, so renaming them breaks the scripts
510+
silently. The whole `tokens/maps` tree ships for the same reason: `getTokenMapFiles` resolves a
511+
file per category, so dropping the per-category files would make every narrower `--category`
512+
throw.
513+
- **`/.well-known/skills/**` is aliased onto `public/agent-skills` in two places, and both are
514+
needed.** `netlify.toml` `[[redirects]]` (with `force = true`) handles production on the CDN;
515+
the `beforeFiles` rewrite in `next.config.js` handles `next dev`, which is what makes
516+
`npx skills add http://localhost:3000` work. `@netlify/plugin-nextjs` does not honour
517+
`beforeFiles` for these paths — without the Netlify rules, `/.well-known/skills/**/*.md` is
518+
claimed by the `/:path*.:ext(txt|md)` rule in `afterFiles` and the `/txt` handler answers 400.
519+
CORS headers are likewise declared in both files, since Next's `headers()` does not apply to a
520+
CDN-resolved rewrite.
521+
522+
### Testing a change
523+
524+
`pnpm --filter=docs test` runs `checkSkillsConfig.ts`, which asserts every `from` pattern still
525+
corresponds to a route in `ai-docs.config.tsx`, that templates and script entry points exist, and
526+
that each index section has content. It needs no build output and runs in PR CI.
527+
528+
Then, from a scratch directory outside the repo:
529+
530+
```bash
531+
pnpm doc:start
532+
cd $(mktemp -d) && npx skills add http://localhost:3000
533+
```
534+
535+
Install from a scratch directory — running it inside `wl-hopper` would write into `.claude/skills`,
536+
which is where this repo's own authoring skills live.
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* CLI entry point for the Hopper skill's icon search.
3+
*
4+
* Bundles the MCP server's `iconsService` verbatim — same Fuse.js configuration, same weights and
5+
* threshold — so the skill and the `get_icons` tool return identical results and a change to the
6+
* search logic reaches both at build time. Only the data path is swapped (see validatorEnv.ts).
7+
*
8+
* Usage:
9+
* node scripts/search-icons.mjs add "new product"
10+
* node scripts/search-icons.mjs --type rich --limit 5 delete
11+
* node scripts/search-icons.mjs --type standard # no query: list them all
12+
*
13+
* Each argument is one query, and a query may hold several space-separated keywords.
14+
*/
15+
import { type IconType, IconTypes, getIcons } from "../../../mcp-server/src/services/iconsService.ts";
16+
17+
function parseArguments(argv: string[]) {
18+
const queries: string[] = [];
19+
let type: IconType = "all";
20+
let limit: number | undefined;
21+
22+
for (let index = 0; index < argv.length; index++) {
23+
const argument = argv[index];
24+
25+
if (argument === "--type") {
26+
const value = argv[++index];
27+
if (!IconTypes.includes(value as IconType)) {
28+
throw new Error(`--type must be one of: ${IconTypes.join(", ")}`);
29+
}
30+
type = value as IconType;
31+
} else if (argument === "--limit") {
32+
limit = Number(argv[++index]);
33+
if (!Number.isFinite(limit) || limit <= 0) {
34+
throw new Error("--limit must be a positive number.");
35+
}
36+
} else if (argument === "--help" || argument === "-h") {
37+
console.log("Usage: node scripts/search-icons.mjs [--type standard|rich|all] [--limit N] <query...>");
38+
process.exit(0);
39+
} else {
40+
queries.push(argument);
41+
}
42+
}
43+
44+
return { queries, type, limit };
45+
}
46+
47+
async function main() {
48+
const { queries, type, limit } = parseArguments(process.argv.slice(2));
49+
const results = await getIcons(queries, type, limit);
50+
51+
for (const [query, icons] of Object.entries(results)) {
52+
console.log(`\n${query}${icons.length} match${icons.length === 1 ? "" : "es"}`);
53+
54+
if (icons.length === 0) {
55+
console.log(" (nothing matched; try a synonym, the keywords field often differs from the name)");
56+
continue;
57+
}
58+
59+
for (const icon of icons) {
60+
console.log(` ${icon.name} [${icon.type}] ${icon.description}`);
61+
}
62+
}
63+
}
64+
65+
main().catch((error: unknown) => {
66+
console.error(error instanceof Error ? error.message : error);
67+
process.exit(2);
68+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* CLI entry point for the Hopper skill's design token lookup.
3+
*
4+
* Bundles the MCP server's `tokensService` verbatim — same filtering, same brief/full output
5+
* shapes — so the skill and the `get_design_tokens` tool return identical results and a change to
6+
* the lookup logic reaches both at build time. Only the data path is swapped (see validatorEnv.ts).
7+
*
8+
* Usage:
9+
* node scripts/search-tokens.mjs --name hop-neutral-text
10+
* node scripts/search-tokens.mjs --css 16px --css "#3C3C3C" --with-css-values
11+
* node scripts/search-tokens.mjs --prop backgroundColor --category semantic-color
12+
* node scripts/search-tokens.mjs --theme sharegate --scheme dark --name hop-primary-surface
13+
*
14+
* With no filter it prints the whole category, which is large — pass at least one of
15+
* --name / --css / --prop unless you actually want the full map.
16+
*/
17+
import {
18+
type ColorScheme,
19+
ColorSchemes,
20+
type Theme,
21+
Themes,
22+
TokenCategories,
23+
type TokenCategory
24+
} from "../../../mcp-server/src/config/constants.ts";
25+
import { getDesignTokens } from "../../../mcp-server/src/services/tokensService.ts";
26+
import { DESIGN_TOKEN_PREFIXES_AND_SUFFIXES } from "../../../mcp-server/src/utils/tokenNameFormatter.ts";
27+
28+
const USAGE = `Usage: node scripts/search-tokens.mjs [options]
29+
30+
--name <token> Filter by Hopper token name, partial match (repeatable).
31+
Pass token names like "hop-neutral-text", not CSS values.
32+
--css <value> Filter by CSS value, fuzzy match (repeatable).
33+
Pass values like "16px", "#3C3C3C", "400".
34+
--prop <prop> Only categories usable with this style prop (repeatable).
35+
--category <cat> Default "all". One of:
36+
${TokenCategories.join(", ")}
37+
--theme <theme> Default "workleap". One of: ${Themes.join(", ")}
38+
--scheme <scheme> Default "light". One of: ${ColorSchemes.join(", ")}
39+
--with-css-values Include each token's CSS value in the output.`;
40+
41+
function parseArguments(argv: string[]) {
42+
const names: string[] = [];
43+
const cssValues: string[] = [];
44+
const props: string[] = [];
45+
let category: TokenCategory = "all";
46+
let theme: Theme = "workleap";
47+
let colorScheme: ColorScheme = "light";
48+
let includeCssValues = false;
49+
50+
function oneOf<T extends string>(value: string, allowed: readonly T[], flag: string): T {
51+
if (!allowed.includes(value as T)) {
52+
throw new Error(`${flag} must be one of: ${allowed.join(", ")}`);
53+
}
54+
55+
return value as T;
56+
}
57+
58+
for (let index = 0; index < argv.length; index++) {
59+
const argument = argv[index];
60+
61+
switch (argument) {
62+
case "--name":
63+
names.push(argv[++index]);
64+
break;
65+
case "--css":
66+
cssValues.push(argv[++index]);
67+
break;
68+
case "--prop":
69+
props.push(argv[++index]);
70+
break;
71+
case "--category":
72+
category = oneOf(argv[++index], TokenCategories, "--category");
73+
break;
74+
case "--theme":
75+
theme = oneOf(argv[++index], Themes, "--theme");
76+
break;
77+
case "--scheme":
78+
colorScheme = oneOf(argv[++index], ColorSchemes, "--scheme");
79+
break;
80+
case "--with-css-values":
81+
includeCssValues = true;
82+
break;
83+
case "--help":
84+
case "-h":
85+
console.log(USAGE);
86+
process.exit(0);
87+
break;
88+
default:
89+
throw new Error(`Unknown argument "${argument}".\n\n${USAGE}`);
90+
}
91+
}
92+
93+
return { names, cssValues, props, category, theme, colorScheme, includeCssValues };
94+
}
95+
96+
async function main() {
97+
const { names, cssValues, props, category, theme, colorScheme, includeCssValues } = parseArguments(
98+
process.argv.slice(2)
99+
);
100+
101+
const results = await getDesignTokens(category, names, cssValues, props, includeCssValues, theme, colorScheme);
102+
103+
if (results.length === 0) {
104+
console.log(
105+
"No tokens matched. Remember: --name takes token names, --css takes CSS values — they are not interchangeable."
106+
);
107+
process.exit(1);
108+
}
109+
110+
for (const result of results) {
111+
console.log(result.text);
112+
}
113+
114+
// The same trailer the MCP tool appends, from the same constant, so the two cannot drift.
115+
console.log(
116+
`\n**Golden Rule**: Remove these substrings from 'token name' to get the correct 'prop value' instantly: ${DESIGN_TOKEN_PREFIXES_AND_SUFFIXES.join(", ")}`
117+
);
118+
}
119+
120+
main().catch((error: unknown) => {
121+
console.error(error instanceof Error ? error.message : error);
122+
process.exit(2);
123+
});

0 commit comments

Comments
 (0)