Files
hanzo.ai/scripts/skill.mjs
zeekay 4449d9234a agents: auth.md, an MCP server card, a skills index, content signals
robots.txt states its Content Signals in both groups: search, ai-input
and ai-train, which the groups already allow. /auth.md says how an
agent gets a credential today (wallet sign-in, device flow, an API key)
and that the auth.md registration protocol is not implemented.

/.well-known/mcp/server-card.json describes the MCP endpoint at
api.hanzo.ai/v1/mcp (SEP-2127, protocol 2026-07-28), and the API
catalog names it. skill.mjs writes /skill.md again as an Agent Skill
with its index (Agent Skills Discovery 0.2.0), the digest taken from
the bytes it writes. wellknown.mjs copies subdirectories and refuses a
server card that does not point at the API host.
2026-10-26 20:41:36 -07:00

114 lines
4.9 KiB
JavaScript

#!/usr/bin/env node
/**
* /skill.md — the file an agent reads before its first call to Hanzo.
*
* Two parts, and only one of them is written by hand. `public/skill.md` carries
* the operating rules, the credential, the install and the MCP server: prose, and
* prose belongs to whoever writes it. Everything Hanzo SERVES is appended here
* out of `lib/data/families.json`, which `scripts/sync-catalog.mjs` reads from
* the document api.hanzo.ai publishes about itself. So the list is never behind
* the API and nobody has to remember it exists.
*
* The same shape as `scripts/llms.mjs` beside it — a committed header, a
* generated body, one file at a fixed address in the export — and loud like
* `scripts/wellknown.mjs`, for the reason that script gives: this is a static
* export, every path answers with the app shell, and a file that failed to
* write returns 200 with HTML rather than a 404 anyone would notice.
*/
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
const FRAME = "public/skill.md";
const SNAPSHOT = "lib/data/families.json";
const CATALOG = "lib/data/catalog.json";
const OUT = "out/skill.md";
const HEADING = "## Families";
const die = (why) => {
console.error(`[skill] ${why}`);
process.exit(1);
};
const frame = readFileSync(FRAME, "utf8").trimEnd();
// The one seam between the two halves. Typing the heading into the frame would
// publish two of this section, one of them stale, and nothing else would say so.
if (frame.includes(HEADING)) die(`${FRAME} carries "${HEADING}" — that section is generated here.`);
const { document, fetched, families } = JSON.parse(readFileSync(SNAPSHOT, "utf8"));
if (!families?.length) die(`${SNAPSHOT} carries no families — run scripts/sync-catalog.mjs.`);
// Headings are the catalog's categories, in its order, and then everything it
// does not sell on its own. A group with no family in it is not printed.
const { categories } = JSON.parse(readFileSync(CATALOG, "utf8"));
const groups = [...categories.map((c) => [c.id, c.label]), [null, "Rest"]];
const count = (n) => `${n} ${n === 1 ? "operation" : "operations"}`;
const line = (f) =>
`- \`/v1/${f.name}\`${f.summary ? ` — ${f.summary}` : ""} (${count(f.operations)})`;
const body = groups
.map(([id, label]) => {
const rows = families.filter((f) => (f.category ?? null) === id);
return rows.length ? `### ${label}\n\n${rows.map(line).join("\n")}\n` : "";
})
.filter(Boolean)
.join("\n");
const operations = families.reduce((n, f) => n + f.operations, 0);
const day = String(fetched).slice(0, 10);
writeFileSync(
OUT,
`${frame}
${HEADING}
The document tags every operation by the first segment after \`/v1/\`, and that
segment is the family. Each line below is a tag the document declares, the
sentence is its own, and the count is how many operations carry it. Headings are
the categories Hanzo's catalog places a family in; \`Rest\` holds every family the
catalog sells no product for, and they answer exactly the same way.
${families.length} families, ${count(operations)}, read from ${document} on ${day}.
${body}`
);
// Read back rather than trust the write, for the reason at the top of this file.
const written = existsSync(OUT) ? readFileSync(OUT, "utf8") : "";
if (!written.includes(HEADING) || written.length <= frame.length) die(`${OUT} did not survive the write`);
console.log(
`[skill] ${OUT} — ${families.length} families · ${count(operations)} · ` +
`${(written.length / 1024).toFixed(0)} KB · document read ${day}`
);
// The same document as an Agent Skill, and the index that names it (Agent Skills
// Discovery v0.2.0, /.well-known/agent-skills/index.json). Its description is the
// summary the frame opens with, and its digest is taken from the bytes this build
// writes, so the index cannot name a file that has moved on. /skill.md itself
// stays as it is: it opens with "# Hanzo", which the copy-prompt flow reads.
const SKILL = "hanzo-cloud-api";
const summary = frame
.split("\n")
.filter((l) => l.startsWith("> "))
.map((l) => l.slice(2).trim())
.join(" ");
if (!summary) die(`${FRAME} opens with no "> " summary to describe the skill with`);
const SKILLS = "out/.well-known/agent-skills";
const ARTIFACT = `${SKILLS}/${SKILL}/SKILL.md`;
mkdirSync(`${SKILLS}/${SKILL}`, { recursive: true });
writeFileSync(ARTIFACT, `---\nname: ${SKILL}\ndescription: ${JSON.stringify(summary)}\n---\n\n${written}`);
const digest = `sha256:${createHash("sha256").update(readFileSync(ARTIFACT)).digest("hex")}`;
writeFileSync(
`${SKILLS}/index.json`,
`${JSON.stringify(
{
$schema: "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
skills: [{ name: SKILL, type: "skill-md", description: summary, url: `/.well-known/agent-skills/${SKILL}/SKILL.md`, digest }],
},
null,
2
)}\n`
);
console.log(`[skill] ${ARTIFACT} · ${SKILLS}/index.json (${digest.slice(0, 19)}…)`);