diff --git a/packages/shared/__tests__/skill-md-sync.test.ts b/packages/shared/__tests__/skill-md-sync.test.ts new file mode 100644 index 0000000..d4aedfc --- /dev/null +++ b/packages/shared/__tests__/skill-md-sync.test.ts @@ -0,0 +1,84 @@ +/** + * Lock-in test: every node type the schema accepts must be documented in + * skills/openhop/SKILL.md, and SKILL.md must not advertise any type the + * schema rejects. Drift between them is a launch-blocking bug — agents + * read SKILL.md to learn the valid type list, write `type: foo`, and the + * server rejects the flow. + * + * This test runs from the workspace-root skills/openhop/SKILL.md, which + * is the canonical source. (The cli's prepack copies it into + * packages/cli/skills/ at publish time; we don't validate that copy here + * because it's regenerated.) + */ + +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { NodeTypeEnum } from '../src/schema.js' + +const here = dirname(fileURLToPath(import.meta.url)) +const SKILL_MD = resolve(here, '..', '..', '..', 'skills', 'openhop', 'SKILL.md') + +const KNOWN_TYPES = new Set(NodeTypeEnum.options) + +/** Pull type names from SKILL.md's "Node Type Variants" table only. + * The first column is the type; subsequent rows include `Type` (header), + * the `---` separator, then one row per type. Stop at the next blank + * line after the table starts so unrelated tables (like patch ops) don't + * leak in. */ +function readSkillTypeTable(): string[] { + const md = readFileSync(SKILL_MD, 'utf-8') + const sectionStart = md.indexOf('Node Type Variants') + expect(sectionStart, 'SKILL.md must have a "Node Type Variants" section').toBeGreaterThan(0) + const slice = md.slice(sectionStart) + const types: string[] = [] + let inTable = false + for (const line of slice.split('\n')) { + if (line.startsWith('|')) { + inTable = true + const m = line.match(/^\|\s*([a-z][a-z0-9-]*)\s*\|/) + if (m && !/^-+$/.test(m[1]) && m[1] !== 'type') types.push(m[1]) + } else if (inTable && line.trim() === '') { + break // table ended + } + } + return types +} + +describe('SKILL.md / schema NodeTypeEnum sync', () => { + it('every type SKILL.md advertises is accepted by the schema', () => { + for (const t of readSkillTypeTable()) { + expect(KNOWN_TYPES.has(t), `SKILL.md mentions type "${t}" but schema rejects it`).toBe(true) + } + }) + + it('every schema type appears in the SKILL.md variants table', () => { + const advertised = new Set(readSkillTypeTable()) + for (const t of NodeTypeEnum.options) { + expect( + advertised.has(t), + `Schema accepts type "${t}" but SKILL.md does not document it` + ).toBe(true) + } + }) + + it('the type-bullet line in SKILL.md lists every schema type', () => { + const md = readFileSync(SKILL_MD, 'utf-8') + // The bullet that documents the closed enum, e.g. + // `type`: closed enum, exactly one of: `actor | endpoint | …` + // We look for the bullet that has both `\`type\`` and at least one + // pipe-separated list of bare names. + const m = md.match(/`type`:[\s\S]{0,200}?`([a-z][a-z0-9 |]+)`/) + expect(m, 'SKILL.md must have a `type`: bullet listing the enum').not.toBeNull() + const listed = new Set( + m![1] + .split('|') + .map((s) => s.trim()) + .filter(Boolean) + ) + for (const t of NodeTypeEnum.options) { + expect(listed.has(t), `SKILL.md type-bullet missing "${t}"`).toBe(true) + } + }) +}) diff --git a/skills/openhop/SKILL.md b/skills/openhop/SKILL.md index 9e5e32a..a6e851e 100644 --- a/skills/openhop/SKILL.md +++ b/skills/openhop/SKILL.md @@ -8,6 +8,49 @@ allowed-tools: Bash(openhop:*), Bash(npx tsx:*) OpenHop renders animated data flow diagrams. You describe the flow in YAML, push it with the CLI, and the user sees animated data pixels traveling between components. +## Quickest valid flow (copy this, modify ids/labels) + +This is the **smallest known-valid flow**. Start from this and edit — do not invent the schema. + +```yaml +meta: + title: Three-tier app +flow: + nodes: + - id: browser + label: Browser + type: actor + - id: api + label: API + type: endpoint + - id: db + label: Postgres + type: database + steps: + - from: browser + to: api + data: request + - from: api + to: db + data: query + - from: db + to: api + data: rows + - from: api + to: browser + data: response +``` + +Push it with `openhop push ` (or `openhop push -` for stdin). On success you get a flow id and a URL. + +**Validation rules to lock in before you write your own:** + +- `type` must be one of the 12 enum values (see Schema Reference below). `transform`, `validation`, `redis`, `oauth`, etc. are **not** valid types. +- `data` is a `string` or an object — never a list. `data: "request"` ✓, `data: { label: "request", fields: [...] }` ✓, `data: [{ name: "request" }]` ✗ +- `id` is alphanumeric + hyphens + underscores only. + +If the validator rejects your flow, **read the error path** — it tells you exactly which field is wrong. + ## Before Creating Flows Verify the OpenHop API server is running: @@ -144,16 +187,21 @@ openhop patch abc123 polish-patch.yaml Prefix all commands with the repo path: ```bash -openhop serve # Start server -openhop serve # Start server + frontend -openhop push # Push a flow → returns ID and URL +openhop serve # Start API + web UI (:8787 + :8788) +openhop validate # Local schema check, no server needed +openhop validate - # Validate from stdin +openhop push # Push a flow → returns id + URL openhop push - # Push from stdin (pipe YAML) +openhop get # Fetch a flow by id (full JSON) +openhop list # List all flows openhop patch # Apply patch operations openhop patch - # Patch from stdin -openhop list # List all flows openhop remove # Delete a flow +openhop help --json # Full machine-readable command tree ``` +Every command supports `--json` for machine-readable output. Use it whenever you'll parse the result. Exit codes are semantic: `0` success, `2` usage, `3` validation, `4` not-found, `5` conflict, `6` network. **Always `validate` before `push`** when iterating — it skips the server round-trip. + Stdin is useful when generating YAML programmatically: ```bash @@ -177,29 +225,34 @@ flow: ### Node - `id` (required): alphanumeric + hyphens + underscores -- `label` (required): display name -- `type`: actor | endpoint | transform | validation | auth | database | external | cache | queue | service | custom -- `icon`: Iconify icon ID (e.g. "logos:postgresql") — overlays on top of the node's pixel art. Works on any `type`, not just `custom`. Browse: https://icon-sets.iconify.design/logos/ +- `label` (required): display name — **freeform**, anything human-readable (`"Stripe Payment Gateway"`, `"Customer #1"`, `"Order Service v2"`) +- `type`: **closed enum, exactly one of**: `actor | endpoint | auth | database | external | cache | queue | service | docker | k8s | scheduler | custom`. Anything else fails validation. Default if omitted: `service`. +- `icon`: Iconify icon ID (e.g. `"logos:postgresql"`) — overlays on top of the node's pixel art. Works on any `type`, not just `custom`. Browse: https://icon-sets.iconify.design/logos/ - `color`: hex color - `flow`: nested sub-flow { nodes, steps } — makes node expandable with + +> **Critical: types are categories, labels are names.** The 12 `type` values are how the renderer knows which sprite + color to draw (database = barrel, cache = lightning, etc.). The `label` is what the user reads on the node. **Never** put a variant name (like `redis`, `oauth`, `stripe`) into `type` — that's a label. Put it in `label`, and use the matching category in `type` (`cache`, `auth`, `external`). +> +> **When nothing fits**, use `type: custom` and set your own `icon` + `color`. Don't invent new type values — the schema is closed. + ### Node Type Variants (pick the right type, then a concrete instance) Each node type has common real-world variants. Use them to choose an accurate `label` and, where applicable, a matching Iconify icon. First entry is the canonical/most common variant for that type. -| Type | Common variants | -| ---------- | ------------------------------------------------------------------------------------------------------------ | -| actor | user, admin, customer, operator, agent, bot, service-account, system | -| endpoint | rest-api, graphql, grpc, webhook, websocket, sse, rpc | -| transform | parser, serializer, formatter, mapper, aggregator, filter, enricher | -| validation | schema-validator, input-validator, permission-check, rate-limiter, csrf, feature-flag | -| auth | oauth, jwt, session, api-key, saml, ldap, mfa | -| database | postgres, mysql, mongodb, sqlite, cassandra, dynamodb, cockroachdb, bigquery, snowflake, elasticsearch, disk | -| external | stripe, twilio, sendgrid, github, slack, openai, anthropic, firebase, s3, maps-api | -| cache | redis, memcached, ram, cdn, http-cache, local-cache | -| queue | kafka, rabbitmq, sqs, pubsub, nats, kinesis, celery | -| service | microservice, worker, scheduler, processor, orchestrator, gateway, proxy, loadbalancer | -| custom | (anything — also set `icon` and `color`) | +| Type | Common variants (use as `label`, NOT `type`) | +| --------- | ------------------------------------------------------------------------------------------------------------ | +| actor | user, admin, customer, operator, agent, bot, service-account, system | +| endpoint | rest-api, graphql, grpc, webhook, websocket, sse, rpc | +| auth | oauth, jwt, session, api-key, saml, ldap, mfa | +| database | postgres, mysql, mongodb, sqlite, cassandra, dynamodb, cockroachdb, bigquery, snowflake, elasticsearch, disk | +| external | stripe, twilio, sendgrid, github, slack, openai, anthropic, firebase, s3, maps-api | +| cache | redis, memcached, ram, cdn, http-cache, local-cache | +| queue | kafka, rabbitmq, sqs, pubsub, nats, kinesis, celery | +| service | microservice, worker, processor, orchestrator, gateway, proxy, loadbalancer | +| docker | container, sidecar, init-container, compose-service | +| k8s | pod, deployment, statefulset, daemonset, job, cronjob, service, ingress | +| scheduler | cron, airflow, temporal, celery-beat, sidekiq, bullmq | +| custom | (anything — also set `icon` and `color`) | ### Step