mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* feat(agent-readiness): Agent Skills discovery index (#3310) Closes #3310. Ships the Agent Skills Discovery v0.2.0 manifest at /.well-known/agent-skills/index.json plus two real, useful skills. Skills are grounded in real sebuf proto RPCs: - fetch-country-brief → GetCountryIntelBrief (public). - fetch-resilience-score → GetResilienceScore (Pro / API key). Each SKILL.md documents endpoint, auth, parameters, response shape, worked curl, errors, and when not to use the skill. scripts/build-agent-skills-index.mjs walks every public/.well-known/agent-skills/<name>/SKILL.md, sha256s the bytes, and emits index.json. Wired into prebuild + every variant build so a deploy can never ship an index whose digests disagree with served files. tests/agent-skills-index.test.mjs asserts the index is up-to-date via the script's --check mode and recomputes every sha256 against the on-disk SKILL.md bytes. Discovery wiring: - public/.well-known/api-catalog: new anchor entry with the agent-skills-index rel per RFC 9727 linkset shape. - vercel.json: adds agent-skills-index rel to the homepage + /index.html Link headers; deploy-config required-rels list updated. Canonical URLs use the apex (worldmonitor.app) since #3322 fixed the apex redirect that previously hid .well-known paths. * fix(agent-readiness): correct auth header + harden frontmatter parser (#3310) Addresses review findings on #3310. ## P1 — auth header was wrong in both SKILL.md files The published skills documented `Authorization: Bearer wm_live_...`, but WorldMonitor API keys must be sent in `X-WorldMonitor-Key`. `Authorization: Bearer` is for MCP/OAuth or Clerk JWTs — not raw `wm_live_...` keys. Agents that followed the SKILL.md verbatim would have gotten 401s despite holding valid keys. fetch-country-brief also incorrectly claimed the endpoint was "public"; server-to-server callers without a trusted browser origin are rejected by `validateApiKey`, so agents do need a key there too. Fixed both SKILL.md files to document `X-WorldMonitor-Key` and cross-link docs/usage-auth as the canonical auth matrix. ## P2 — frontmatter parser brittleness The hand-rolled parser used `indexOf('\n---', 4)` as the closing fence, which matched any body line that happened to start with `---`. Swapped for a regex that anchors the fence to its own line, and delegated value parsing to js-yaml (already a project dep) so future catalog growth (quoted colons, typed values, arrays) does not trip new edge cases. Added parser-contract tests that lock in the new semantics: body `---` does not terminate the block, values with colons survive intact, non-mapping frontmatter throws, and no-frontmatter files return an empty mapping. Index.json rebuilt against the updated SKILL.md bytes.
107 lines
3.5 KiB
JavaScript
107 lines
3.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// Emits public/.well-known/agent-skills/index.json per the Agent Skills
|
|
// Discovery RFC v0.2.0. Each entry points at a SKILL.md and carries a
|
|
// sha256 of that file's exact served bytes, so agents can verify the
|
|
// skill text hasn't changed since they last fetched it.
|
|
//
|
|
// Source of truth: public/.well-known/agent-skills/<name>/SKILL.md
|
|
// Output: public/.well-known/agent-skills/index.json
|
|
//
|
|
// Run locally via `npm run build:agent-skills`. CI re-runs this and
|
|
// diffs the output against the committed index.json to block drift.
|
|
|
|
import { readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
import { createHash } from 'node:crypto';
|
|
import { resolve, dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import yaml from 'js-yaml';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const ROOT = resolve(dirname(__filename), '..');
|
|
const SKILLS_DIR = resolve(ROOT, 'public/.well-known/agent-skills');
|
|
const INDEX_PATH = join(SKILLS_DIR, 'index.json');
|
|
const PUBLIC_BASE = 'https://worldmonitor.app';
|
|
|
|
const SCHEMA = 'https://agentskills.io/schemas/v0.2.0/index.json';
|
|
// Closing fence must be anchored to its own line so values that happen to
|
|
// start with `---` in the body can't prematurely terminate frontmatter.
|
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/;
|
|
|
|
function sha256Hex(bytes) {
|
|
return createHash('sha256').update(bytes).digest('hex');
|
|
}
|
|
|
|
export function parseFrontmatter(md) {
|
|
const match = FRONTMATTER_RE.exec(md);
|
|
if (!match) return {};
|
|
const parsed = yaml.load(match[1]);
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
throw new Error('Frontmatter must be a YAML mapping');
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function collectSkills() {
|
|
const entries = readdirSync(SKILLS_DIR, { withFileTypes: true })
|
|
.filter((d) => d.isDirectory())
|
|
.map((d) => d.name)
|
|
.sort();
|
|
|
|
return entries.map((name) => {
|
|
const skillPath = join(SKILLS_DIR, name, 'SKILL.md');
|
|
const stat = statSync(skillPath);
|
|
if (!stat.isFile()) {
|
|
throw new Error(`Expected ${skillPath} to exist and be a file`);
|
|
}
|
|
const bytes = readFileSync(skillPath);
|
|
const md = bytes.toString('utf-8');
|
|
const fm = parseFrontmatter(md);
|
|
if (!fm.description) {
|
|
throw new Error(`${skillPath} missing "description" in frontmatter`);
|
|
}
|
|
if (fm.name && fm.name !== name) {
|
|
throw new Error(
|
|
`${skillPath} frontmatter name="${fm.name}" disagrees with directory "${name}"`,
|
|
);
|
|
}
|
|
return {
|
|
name,
|
|
type: 'task',
|
|
description: fm.description,
|
|
url: `${PUBLIC_BASE}/.well-known/agent-skills/${name}/SKILL.md`,
|
|
sha256: sha256Hex(bytes),
|
|
};
|
|
});
|
|
}
|
|
|
|
function build() {
|
|
const skills = collectSkills();
|
|
if (skills.length === 0) {
|
|
throw new Error(`No skills found under ${SKILLS_DIR}`);
|
|
}
|
|
const index = { $schema: SCHEMA, skills };
|
|
return JSON.stringify(index, null, 2) + '\n';
|
|
}
|
|
|
|
function main() {
|
|
const content = build();
|
|
const check = process.argv.includes('--check');
|
|
if (check) {
|
|
const current = readFileSync(INDEX_PATH, 'utf-8');
|
|
if (current !== content) {
|
|
process.stderr.write(
|
|
'agent-skills index.json is out of date. Run `npm run build:agent-skills`.\n',
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write('agent-skills index.json is up to date.\n');
|
|
return;
|
|
}
|
|
writeFileSync(INDEX_PATH, content);
|
|
process.stdout.write(`Wrote ${INDEX_PATH}\n`);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
main();
|
|
}
|