From 55b45006e280b1c75a0a5f5839ddcdf3b480ce9b Mon Sep 17 00:00:00 2001 From: Oto Macenauer Date: Sat, 5 Sep 2026 16:45:50 +0200 Subject: [PATCH] feat(skills): add the kb-docs-add agent skill for onboarding docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent asked to "get our docs into the knowledge base" had to find the contract, read it in the right order and hand-translate it — and tended to over-build: a pack script, a manifest for a single-page repo, a workflow copied from a pre-v1 fork. The repo owns the source of truth, so it now also owns the agent-facing path through it. skills/kb-docs-add/ follows the Agent Skills spec, so the same directory loads in Claude Code, GitHub Copilot and via `npx skills add`. It classifies the repo (single-page / packaged / iframe), writes only the files the contract requires, and maps every message the actions emit to its fix. It ships no scripts: the actions keep owning validation, packing and upload. Its examples are the contract's own code blocks, and tests/skill.spec.js fails if they drift or the frontmatter stops being portable. Eval fixtures (three repos + evals.json) live under tests/fixtures/ so the installed skill stays small. Skill-driven runs on them and on clean copies of both example repos reproduced the committed workflows. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01PYtmxSeSXFg4wEXRcKCmAi --- CLAUDE.md | 5 + README.md | 18 ++ contract/HEADLESS_RULES.md | 7 + contract/SINGLE_PAGE.md | 7 + skills/README.md | 56 +++++ skills/kb-docs-add/SKILL.md | 110 ++++++++++ skills/kb-docs-add/examples/kb-docs.json | 18 ++ .../examples/packaged.publish-docs.yml | 24 +++ .../examples/single-page.publish-docs.yml | 22 ++ skills/kb-docs-add/references/packaged.md | 196 ++++++++++++++++++ skills/kb-docs-add/references/single-page.md | 108 ++++++++++ .../kb-docs-add/references/troubleshooting.md | 89 ++++++++ tests/fixtures/kb-docs-add/evals.json | 59 ++++++ .../kb-docs-add/hosted-elsewhere/README.md | 7 + .../kb-docs-add/hosted-elsewhere/src/main.go | 1 + .../kb-docs-add/markdown-only/CHANGELOG.md | 4 + .../kb-docs-add/markdown-only/README.md | 7 + .../markdown-only/docs/overview.md | 23 ++ .../kb-docs-add/markdown-only/docs/runbook.md | 14 ++ .../kb-docs-add/markdown-only/src/main.go | 1 + .../kb-docs-add/mkdocs-site/.gitignore | 2 + .../mkdocs-site/docs/getting-started.md | 3 + .../kb-docs-add/mkdocs-site/docs/index.md | 4 + .../mkdocs-site/docs/reference/config.md | 5 + .../kb-docs-add/mkdocs-site/mkdocs.yml | 17 ++ .../kb-docs-add/mkdocs-site/requirements.txt | 1 + .../kb-docs-add/mkdocs-site/theme/main.html | 31 +++ .../kb-docs-add/mkdocs-site/theme/style.css | 3 + tests/skill.spec.js | 175 ++++++++++++++++ 29 files changed, 1017 insertions(+) create mode 100644 skills/README.md create mode 100644 skills/kb-docs-add/SKILL.md create mode 100644 skills/kb-docs-add/examples/kb-docs.json create mode 100644 skills/kb-docs-add/examples/packaged.publish-docs.yml create mode 100644 skills/kb-docs-add/examples/single-page.publish-docs.yml create mode 100644 skills/kb-docs-add/references/packaged.md create mode 100644 skills/kb-docs-add/references/single-page.md create mode 100644 skills/kb-docs-add/references/troubleshooting.md create mode 100644 tests/fixtures/kb-docs-add/evals.json create mode 100644 tests/fixtures/kb-docs-add/hosted-elsewhere/README.md create mode 100644 tests/fixtures/kb-docs-add/hosted-elsewhere/src/main.go create mode 100644 tests/fixtures/kb-docs-add/markdown-only/CHANGELOG.md create mode 100644 tests/fixtures/kb-docs-add/markdown-only/README.md create mode 100644 tests/fixtures/kb-docs-add/markdown-only/docs/overview.md create mode 100644 tests/fixtures/kb-docs-add/markdown-only/docs/runbook.md create mode 100644 tests/fixtures/kb-docs-add/markdown-only/src/main.go create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/.gitignore create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/docs/getting-started.md create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/docs/index.md create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/docs/reference/config.md create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/mkdocs.yml create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/requirements.txt create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/theme/main.html create mode 100644 tests/fixtures/kb-docs-add/mkdocs-site/theme/style.css create mode 100644 tests/skill.spec.js diff --git a/CLAUDE.md b/CLAUDE.md index 92ef942..80f718e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,7 @@ Orchestrator: `scripts/build-vite.js`. Flags: `--local`, `--headless`. - `scripts/artifacts.js` — Safe tarball extraction + tree copy, shared by both fetch paths. Validates archive members (no traversal, no absolute paths, no symlinks) before anything is written, and replaces the old `cp -r`/`tar` shell-outs so the build runs on Windows - `scripts/hoist-inline-scripts.js` — Moves inline ` + + diff --git a/tests/fixtures/kb-docs-add/mkdocs-site/theme/style.css b/tests/fixtures/kb-docs-add/mkdocs-site/theme/style.css new file mode 100644 index 0000000..6e7f361 --- /dev/null +++ b/tests/fixtures/kb-docs-add/mkdocs-site/theme/style.css @@ -0,0 +1,3 @@ +:root { --bg-page: #f8f9fb; --text-body: #374151; } +.topnav { position: fixed; top: 0; height: 56px; } +#sidebar { position: sticky; top: 56px; } diff --git a/tests/skill.spec.js b/tests/skill.spec.js new file mode 100644 index 0000000..384caf6 --- /dev/null +++ b/tests/skill.spec.js @@ -0,0 +1,175 @@ +/** + * tests/skill.spec.js + * + * `skills/kb-docs-add/` is the agent-facing path through the onboarding contract: + * a SKILL.md that Claude Code, GitHub Copilot and `npx skills add` all read, plus + * references and copy-paste examples. Two things keep it honest, and nothing else + * exercises either: + * + * 1. The examples are the contract's own code blocks, byte for byte. A skill that + * carried its own copy of the workflow would drift the first time the contract + * changed, and an agent would then write the stale version into a real repo. + * 2. The frontmatter satisfies the Agent Skills spec every consumer validates + * against (name ↔ directory, lengths, character set), so an install that + * succeeds on one agent does not fail on another. + * + * Static assertions; no network. + */ + +import { test, expect } from '@playwright/test'; +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); +const SKILL_DIR = 'skills/kb-docs-add'; +const SKILL_NAME = 'kb-docs-add'; + +// A CRLF checkout (the Git for Windows default) must not break the `\n`-anchored +// comparisons below. +const read = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n'); + +/** First fenced block of `lang` after `heading`, as the contract prints it. */ +function fencedBlockAfter(markdown, heading, lang) { + const at = markdown.indexOf(heading); + expect(at, `heading ${JSON.stringify(heading)} not found`).toBeGreaterThanOrEqual(0); + const m = markdown.slice(at).match(new RegExp('```' + lang + '\\n([\\s\\S]*?)\\n```')); + expect(m, `no ${lang} block after ${JSON.stringify(heading)}`).not.toBeNull(); + return m[1] + '\n'; +} + +function frontmatter(markdown) { + const m = markdown.match(/^---\n([\s\S]*?)\n---\n/); + expect(m, 'SKILL.md must open with a YAML frontmatter block').not.toBeNull(); + const fields = {}; + for (const line of m[1].split('\n')) { + const kv = line.match(/^([a-z-]+):\s*(.*)$/); + if (kv) fields[kv[1]] = kv[2]; + } + return { fields, body: markdown.slice(m[0].length) }; +} + +/** Every "name/with/slashes.md"-looking path mentioned in SKILL.md, resolved to the skill dir. */ +function referencedPaths(markdown) { + const out = new Set(); + for (const m of markdown.matchAll(/`((?:references|examples)\/[A-Za-z0-9._-]+)`/g)) out.add(m[1]); + return [...out]; +} + +test.describe('SKILL.md frontmatter satisfies the Agent Skills spec', () => { + const { fields, body } = frontmatter(read(`${SKILL_DIR}/SKILL.md`)); + + test('name matches the directory and the spec character set', () => { + expect(fields.name).toBe(SKILL_NAME); + expect(fields.name).toMatch(/^[a-z0-9]+(-[a-z0-9]+)*$/); + expect(fields.name.length).toBeLessThanOrEqual(64); + }); + + test('description is present, within 1024 characters, and names the triggers', () => { + expect(fields.description).toBeTruthy(); + expect(fields.description.length).toBeLessThanOrEqual(1024); + for (const trigger of ['knowledge base', 'kb-docs.json', 'kb-docs.tar.gz', 'publish-single-page-docs', 'publish-docs']) { + expect(fields.description, `description should mention ${trigger}`).toContain(trigger); + } + }); + + test('only portable frontmatter fields are used', () => { + // `allowed-tools` is Claude-only and experimental; a skill installed into + // .github/skills must not depend on it. + const portable = new Set(['name', 'description', 'license', 'compatibility', 'metadata']); + for (const key of Object.keys(fields)) { + if (key === 'author' || key === 'source') continue; // nested under metadata + expect(portable.has(key), `frontmatter field "${key}" is not in the spec`).toBe(true); + } + }); + + test('body stays short enough to load whole; detail lives in references/', () => { + const lines = body.split('\n').length; + expect(lines).toBeLessThanOrEqual(160); + }); +}); + +test.describe('the skill is guidance only', () => { + test('ships no scripts/ directory and no executable code', () => { + expect(existsSync(join(ROOT, SKILL_DIR, 'scripts'))).toBe(false); + const walk = (dir) => + readdirSync(join(ROOT, dir)).flatMap((name) => { + const rel = `${dir}/${name}`; + return statSync(join(ROOT, rel)).isDirectory() ? walk(rel) : [rel]; + }); + const code = walk(SKILL_DIR).filter((f) => /\.(js|mjs|cjs|ts|py|sh|ps1)$/.test(f)); + expect(code, 'the actions own every check; the skill only points at them').toEqual([]); + }); + + test('every references/ and examples/ path SKILL.md names exists', () => { + const skill = read(`${SKILL_DIR}/SKILL.md`); + const mentioned = referencedPaths(skill); + expect(mentioned.length).toBeGreaterThan(0); + for (const rel of mentioned) { + expect(existsSync(join(ROOT, SKILL_DIR, rel)), `${rel} is referenced but missing`).toBe(true); + } + for (const required of ['references/single-page.md', 'references/packaged.md', 'references/troubleshooting.md']) { + expect(mentioned, `SKILL.md must point the agent at ${required}`).toContain(required); + } + }); + + test('never names a pre-v1 artifact as something to produce', () => { + // The strings may appear as things to *avoid*; they must not appear in an example. + for (const file of readdirSync(join(ROOT, SKILL_DIR, 'examples'))) { + const text = read(`${SKILL_DIR}/examples/${file}`); + for (const legacy of ['dist.tar.gz', 'marketplace.json', 'bundle.json', 'data-mp-headless', '@master']) { + expect(text, `${file} contains ${legacy}`).not.toContain(legacy); + } + } + }); +}); + +test.describe('examples are the contract, verbatim', () => { + const singlePage = read('contract/SINGLE_PAGE.md'); + const headless = read('contract/HEADLESS_RULES.md'); + + const CASES = [ + ['examples/single-page.publish-docs.yml', singlePage, '## 1. Add the workflow', 'yaml'], + ['examples/packaged.publish-docs.yml', headless, '## Required: GitHub Release artifact', 'yaml'], + ['examples/kb-docs.json', headless, '## Required: `kb-docs.json` manifest', 'json'], + ]; + + for (const [example, source, heading, lang] of CASES) { + test(`${example} equals the block under ${JSON.stringify(heading)}`, () => { + expect(read(`${SKILL_DIR}/${example}`)).toBe(fencedBlockAfter(source, heading, lang)); + }); + } + + test('references/packaged.md carries the brand token values STYLE_GUIDE.md fixes', () => { + // The packaged reference hands the agent a paste-ready :root block, because a docs + // repo rarely has this repository checked out beside it. The brand hexes in that + // block must be the ones the style guide's palette table names. + const styleGuide = read('contract/STYLE_GUIDE.md'); + const packaged = read(`${SKILL_DIR}/references/packaged.md`); + for (const token of ['--color-kb-500', '--color-kb-600']) { + const row = styleGuide.match(new RegExp('\\| `' + token + '`\\s*\\| `(#[0-9a-f]{6})`')); + expect(row, `${token} missing from the STYLE_GUIDE palette table`).not.toBeNull(); + expect(packaged, `${token} should be ${row[1]} in packaged.md`).toMatch(new RegExp(token + ':\\s*' + row[1])); + } + // Page background and heading colour derive from kb-25 and kb-950 in knowledge-base.css. + expect(packaged).toMatch(/--bg-page:\s*#fdf8f9/); + expect(packaged).toMatch(/--text-heading:\s*#1b0e12/); + }); + + test('the manifest example validates as JSON with kbVersion "1"', () => { + const manifest = JSON.parse(read(`${SKILL_DIR}/examples/kb-docs.json`)); + expect(manifest.kbVersion).toBe('1'); + expect(Array.isArray(manifest.apps) && manifest.apps.length).toBeTruthy(); + }); +}); + +test.describe('the docs point at the skill', () => { + const INSTALL = 'npx skills add AbsaOSS/knowledge-base'; + for (const rel of ['README.md', 'contract/SINGLE_PAGE.md', 'contract/HEADLESS_RULES.md']) { + test(`${rel} names ${SKILL_NAME} and the install command`, () => { + const text = read(rel); + expect(text).toContain(SKILL_NAME); + expect(text).toContain(INSTALL); + }); + } +});