diff --git a/.agents/skills/.claude-plugin/plugin.json b/.agents/skills/.claude-plugin/plugin.json new file mode 100644 index 000000000000..9e5e266bc392 --- /dev/null +++ b/.agents/skills/.claude-plugin/plugin.json @@ -0,0 +1,24 @@ +{ + "name": "data-client", + "displayName": "Reactive Data Client", + "description": "Sets up Data Client, then points your agent at the skills shipped in your installed @data-client packages, so React or Vue apps get the guidance for their version", + "author": { + "name": "Nathaniel Tucker", + "email": "me@ntucker.me" + }, + "homepage": "https://dataclient.io/docs/getting-started/agent-skills", + "repository": "https://github.com/reactive/data-client", + "license": "Apache-2.0", + "keywords": [ + "data-client", + "react", + "vue", + "rest", + "graphql", + "normalized-cache" + ], + "skills": ["./data-client", "./data-client-setup"], + "icon": "./assets/logo.svg", + "documentationUrl": "https://dataclient.io/docs/getting-started/agent-skills", + "supportUrl": "https://github.com/reactive/data-client/issues" +} diff --git a/.agents/skills/.cursor-plugin/plugin.json b/.agents/skills/.cursor-plugin/plugin.json index 642a6d8b0b11..e46c20c16059 100644 --- a/.agents/skills/.cursor-plugin/plugin.json +++ b/.agents/skills/.cursor-plugin/plugin.json @@ -1,7 +1,6 @@ { "name": "data-client", "displayName": "Reactive Data Client", - "version": "1.0.0", "description": "Sets up Data Client, then points your agent at the skills shipped in your installed @data-client packages, so React or Vue apps get the guidance for their version", "author": { "name": "Nathaniel Tucker", @@ -18,8 +17,9 @@ "graphql", "normalized-cache" ], + "skills": ["./data-client", "./data-client-setup"], + "version": "1.0.0", "category": "developer-tools", "tags": ["react", "vue", "data-fetching"], - "logo": "assets/logo.svg", - "skills": ["./data-client", "./data-client-setup"] + "logo": "assets/logo.svg" } diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 000000000000..06bb638dba8e --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,58 @@ +# Reactive Data Client + +[Reactive Data Client](https://dataclient.io) manages server state in React and Vue apps: fetching, caching, +mutations and real-time updates, all kept consistent through a normalized store. This plugin teaches your coding +agent to use it well. + +The plugin carries two skills: + +- **`data-client`** starts any task in an app that uses Data Client, or that should. It sends the agent to the + skill for the work at hand: schemas, REST endpoints, React hooks, Vue composables, tests, or real-time managers. +- **`data-client-setup`** adds Data Client to an app. It runs only when you ask for it. It detects your framework + (NextJS, Expo, React Native, Vue or plain React) and protocol (REST, GraphQL or other async code), tells you which + packages it will install, installs them with your package manager, adds the provider, and follows the setup guide + for your protocol. + +Every other skill ships inside the `@data-client/*` package it covers, so its guidance always matches the version +your project installs. The setup skill links them into your project's `.agents/skills` folder (and `.claude/skills`) +by running `npx --no data-client-skills`, which uses only your installed packages and never downloads one. + +## Install + +With Claude Code, for everyone working on the project: + +```bash +claude plugin marketplace add reactive/data-client --scope project \ + --sparse .claude-plugin .agents/skills +claude plugin install data-client@data-client --scope project +``` + +In Cursor's agent chat: + +``` +/add-plugin data-client@https://github.com/reactive/data-client +``` + +In Codex: + +```bash +codex plugin marketplace add reactive/data-client \ + --sparse .claude-plugin --sparse .agents/skills +codex plugin add data-client@data-client +``` + +Then ask your agent to set up Data Client, or start the setup skill yourself: `/data-client:data-client-setup` in +Claude Code, `/data-client-setup` in Cursor, `$data-client:data-client-setup` in Codex. + +## What it runs + +The plugin has no hooks, MCP servers or scripts of its own, and it sends no data anywhere. When you ask it to set up +Data Client, the agent runs your package manager to install the `@data-client/*` packages and runs the +`data-client-skills` command those packages include. Projects on packages older than 0.19 install the skills with +the [skills CLI](https://www.npmjs.com/package/skills) instead. + +## Learn more + +- [Agent skills guide](https://dataclient.io/docs/getting-started/agent-skills) +- [Documentation](https://dataclient.io/docs) +- [Source](https://github.com/reactive/data-client), licensed under Apache-2.0 diff --git a/.agents/skills/assets/logo.svg b/.agents/skills/assets/logo.svg index cfe8593d10fc..59f2742f5c01 100644 --- a/.agents/skills/assets/logo.svg +++ b/.agents/skills/assets/logo.svg @@ -1,22 +1,17 @@ - - - - - - - - - - - - - + + + + + + + + + + diff --git a/.claude-plugin/README.md b/.claude-plugin/README.md index a460bde43b29..e66c98c186b8 100644 --- a/.claude-plugin/README.md +++ b/.claude-plugin/README.md @@ -11,12 +11,28 @@ endpoint, rest, graphql, react and vue, implemented in `packages/core/agents/` a | File | What | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `marketplace.json` | The marketplace Claude Code reads (`claude plugin marketplace add reactive/data-client`). `data-client` is the plugin; `core`, `react`, `vue` and `migrations` are old names kept as copies so existing installs keep updating (`plans/next-breaking-release.md` removes them). | -| `cursor.mjs` | Writes Cursor's copy of the plugin (see below). | -| `*.test.mjs` | `node --test '.claude-plugin/*.test.mjs'`: `marketplace.json` and the Cursor copy against the repo; READMEs against the site. | +| `plugin-dir.mjs` | Writes the manifests that make `.agents/skills` a self-contained copy of the plugin, for Cursor and Claude's plugin directory (see below). | +| `openai-zip.mjs` | Packs the plugin as the ZIP OpenAI's plugin directory uploads (see below). | +| `*.test.mjs` | `node --test '.claude-plugin/*.test.mjs'`: `marketplace.json` and the plugin directory copies against the repo; READMEs against the site. | ## Codex and Cursor -Codex reads `marketplace.json` as is (`codex plugin add data-client@data-client`). Cursor plugins can't reach files outside their folder, so `cursor.mjs` writes a copy of the `data-client` entry rooted at `.agents/skills` (`.cursor-plugin/marketplace.json`, `.agents/skills/.cursor-plugin/plugin.json`), and `marketplace.test.mjs` fails when they drift. The marketplace source is a `git-subdir` pinned to `master`: Cursor's `/add-plugin` otherwise checks out `main`, which this repo does not have. Cursor's published `marketplace.schema.json` still types `source` as a string; the object form comes from its plugin docs, and the Cursor agent's install resolved it. +Codex reads `marketplace.json` as is (`codex plugin add data-client@data-client`). Cursor plugins can't reach files outside their folder, so `plugin-dir.mjs` writes a copy of the `data-client` entry rooted at `.agents/skills` (`.cursor-plugin/marketplace.json`, `.agents/skills/.cursor-plugin/plugin.json`, and the `.agents/skills/.claude-plugin/plugin.json` Claude's directory reads), and `marketplace.test.mjs` fails when they drift. The marketplace source is a `git-subdir` pinned to `master`: Cursor's `/add-plugin` otherwise checks out `main`, which this repo does not have. Cursor's published `marketplace.schema.json` still types `source` as a string; the object form comes from its plugin docs, and the Cursor agent's install resolved it. + +## Public listings + +Each listing reads the plugin from `.agents/skills`, whose `README.md` is the listing's description. + +- **Claude's plugin directory** ([claude.ai/directory/manage](https://claude.ai/directory/manage), plugin path + `.agents/skills`, branch `master`) reads `.agents/skills/.claude-plugin/plugin.json`, written without a `version` so + every commit is an update. The `agent-rules` workflow checks its manifest and skills with `claude plugin validate ./.agents/skills`; the + directory's safety scan and policy review run only on submission. The + directory refuses repositories whose GitHub archive passes 50 MiB. +- **Cursor Marketplace** ([cursor.com/marketplace/publish](https://cursor.com/marketplace/publish)) reviews the + repository link and `.cursor-plugin/marketplace.json`. +- **OpenAI's plugin directory** (ChatGPT and Codex) takes an uploaded ZIP, not the repository: + `node .claude-plugin/openai-zip.mjs` writes `data-client-plugin.zip`, versioned by its build time. Upload a new one + whenever the plugin's skills change. ## What it reads from the rest of the repo @@ -28,5 +44,5 @@ Codex reads `marketplace.json` as is (`codex plugin add data-client@data-client` ## CI The `agent-rules` workflow runs these tests and the `data-client-skills` tests (`packages/core/agents/*.test.mjs`), with -no install. Its `paths` cover this directory and the inputs above, and `marketplace.test.mjs` fails when the old names -drift from `data-client` or a plugin skill goes missing or stops shipping in a package. `cursor-plugin.test.mjs` checks that the Cursor manifests still install. +no install, then fetches a pinned Claude Code CLI for `claude plugin validate`. Its `paths` cover this directory and the inputs above, and `marketplace.test.mjs` fails when the old names +drift from `data-client` or a plugin skill goes missing or stops shipping in a package. `plugin-dir.test.mjs` checks the skill names, logo and README the plugin directory listings read. diff --git a/.claude-plugin/cursor.mjs b/.claude-plugin/cursor.mjs deleted file mode 100644 index 96eac7173f42..000000000000 --- a/.claude-plugin/cursor.mjs +++ /dev/null @@ -1,82 +0,0 @@ -// Writes the Cursor copy of the `data-client` plugin: node .claude-plugin/cursor.mjs -// Cursor plugins can't reach files outside their folder, so it's rooted at -// .agents/skills. marketplace.test.mjs fails when the written files drift -// from marketplace.json. -// -// `/add-plugin` checks out ref "main" when the marketplace entry's source -// doesn't pin one, and a branch on the GitHub URL is ignored. This repo's -// default branch is master, so a relative source fails with -// `git ls-remote did not return a resolvable commit for ref "main"`. -// git-subdir sparse-checks out only the plugin directory at master. -import fs from 'node:fs'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; - -export const root = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '..', -); -const skills = '.agents/skills'; -const repository = 'https://github.com/reactive/data-client'; - -/** Cursor's manifests (path => JSON): the `data-client` entry of - * marketplace.json plus Cursor's own listing fields */ -export function cursorFiles() { - const marketplace = JSON.parse( - fs.readFileSync(path.join(root, '.claude-plugin/marketplace.json'), 'utf8'), - ); - const plugin = marketplace.plugins.find(p => p.name === marketplace.name); - return { - '.cursor-plugin/marketplace.json': { - name: marketplace.name, - owner: marketplace.owner, - metadata: marketplace.metadata, - plugins: [ - { - name: plugin.name, - source: { - source: 'git-subdir', - url: `${repository}.git`, - path: skills, - ref: 'master', - }, - description: plugin.description, - }, - ], - }, - [`${skills}/.cursor-plugin/plugin.json`]: { - name: plugin.name, - displayName: 'Reactive Data Client', - version: '1.0.0', - description: plugin.description, - author: marketplace.owner, - homepage: plugin.homepage, - repository, - license: plugin.license, - keywords: [ - 'data-client', - 'react', - 'vue', - 'rest', - 'graphql', - 'normalized-cache', - ], - category: 'developer-tools', - tags: ['react', 'vue', 'data-fetching'], - logo: 'assets/logo.svg', - // Each entry is a skill directory (it contains SKILL.md). Listing the - // plugin root would also publish the maintainer skills beside them. - skills: plugin.skills.map(skill => `./${path.relative(skills, skill)}`), - }, - }; -} - -if (process.argv[1] === fileURLToPath(import.meta.url)) { - for (const [file, json] of Object.entries(cursorFiles())) { - fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); - fs.writeFileSync( - path.join(root, file), - `${JSON.stringify(json, null, 2)}\n`, - ); - } -} diff --git a/.claude-plugin/marketplace.test.mjs b/.claude-plugin/marketplace.test.mjs index 6efb7a06efa3..f470df26beea 100644 --- a/.claude-plugin/marketplace.test.mjs +++ b/.claude-plugin/marketplace.test.mjs @@ -27,9 +27,9 @@ test('plugin skills exist and ship in a package', () => { } }); -test('the Cursor plugin matches marketplace.json', async () => { - // regenerate with node .claude-plugin/cursor.mjs - const { cursorFiles } = await import('./cursor.mjs'); - for (const [file, json] of Object.entries(cursorFiles())) +test('the plugin directory matches marketplace.json', async () => { + // regenerate with node .claude-plugin/plugin-dir.mjs + const { pluginDirFiles } = await import('./plugin-dir.mjs'); + for (const [file, json] of Object.entries(pluginDirFiles())) assert.deepEqual(readJSON(file), json, file); }); diff --git a/.claude-plugin/openai-zip.mjs b/.claude-plugin/openai-zip.mjs new file mode 100644 index 000000000000..10682cbc9d22 --- /dev/null +++ b/.claude-plugin/openai-zip.mjs @@ -0,0 +1,46 @@ +// Packs the `data-client` plugin as the ZIP OpenAI's plugin directory +// (ChatGPT and Codex) uploads: node .claude-plugin/openai-zip.mjs [out.zip] +// The directory takes a Claude plugin archive with each skill at +// skills//SKILL.md, and doesn't follow the repo, so upload a new ZIP +// whenever the plugin's skills change. +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { pluginDirFiles, root } from './plugin-dir.mjs'; + +const skills = path.join(root, '.agents/skills'); +const out = path.resolve(process.argv[2] ?? 'data-client-plugin.zip'); +const manifest = pluginDirFiles()['.agents/skills/.claude-plugin/plugin.json']; +// OpenAI requires a semver `version` that grows with each upload: the UTC +// date and time of the build. The repo's manifest has none so Claude Code +// updates on every commit. +const [date, time] = new Date().toISOString().replace(/[-:]/g, '').split('T'); +const version = `1.${date}.${Number(time.slice(0, 6))}`; +const stage = fs.mkdtempSync(path.join(os.tmpdir(), 'data-client-plugin-')); + +try { + for (const skill of manifest.skills) + fs.cpSync(path.join(skills, skill), path.join(stage, 'skills', skill), { + recursive: true, + dereference: true, + // repo tooling, left out like scripts/pack-skills.mjs does + filter: src => !['evals', 'references.json'].includes(path.basename(src)), + }); + for (const file of ['README.md', manifest.icon]) + fs.cpSync(path.join(skills, file), path.join(stage, file)); + fs.cpSync(path.join(root, 'LICENSE'), path.join(stage, 'LICENSE')); + fs.mkdirSync(path.join(stage, '.claude-plugin')); + fs.writeFileSync( + path.join(stage, '.claude-plugin/plugin.json'), + `${JSON.stringify({ ...manifest, version, skills: './skills/' }, null, 2)}\n`, + ); + + fs.rmSync(out, { force: true }); + // needs the zip CLI + execFileSync('zip', ['-qrX', out, '.'], { cwd: stage }); +} finally { + fs.rmSync(stage, { recursive: true }); +} +console.log(`Wrote ${path.relative(process.cwd(), out)}`); diff --git a/.claude-plugin/plugin-dir.mjs b/.claude-plugin/plugin-dir.mjs new file mode 100644 index 000000000000..e467c1736d6c --- /dev/null +++ b/.claude-plugin/plugin-dir.mjs @@ -0,0 +1,98 @@ +// Writes the manifests that make .agents/skills a self-contained copy of the +// `data-client` plugin: node .claude-plugin/plugin-dir.mjs +// Cursor plugins and plugins listed in Claude's directory can't reach files +// outside their folder, so they're rooted at .agents/skills. +// marketplace.test.mjs fails when the written files drift from +// marketplace.json. +// +// `/add-plugin` checks out ref "main" when the marketplace entry's source +// doesn't pin one, and a branch on the GitHub URL is ignored. This repo's +// default branch is master, so a relative source fails with +// `git ls-remote did not return a resolvable commit for ref "main"`. +// git-subdir sparse-checks out only the plugin directory at master. +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const root = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', +); +const skills = '.agents/skills'; +const repository = 'https://github.com/reactive/data-client'; +const logo = 'assets/logo.svg'; + +/** The plugin directory's manifests (path => JSON): the `data-client` entry + * of marketplace.json plus each listing's own fields */ +export function pluginDirFiles() { + const marketplace = JSON.parse( + fs.readFileSync(path.join(root, '.claude-plugin/marketplace.json'), 'utf8'), + ); + const plugin = marketplace.plugins.find(p => p.name === marketplace.name); + // the fields both plugin.json listings share + const listing = { + name: plugin.name, + displayName: 'Reactive Data Client', + description: plugin.description, + author: marketplace.owner, + homepage: plugin.homepage, + repository, + license: plugin.license, + keywords: [ + 'data-client', + 'react', + 'vue', + 'rest', + 'graphql', + 'normalized-cache', + ], + // Each entry is a skill directory (it contains SKILL.md). Listing the + // plugin root would also publish the maintainer skills beside them. + skills: plugin.skills.map(skill => `./${path.relative(skills, skill)}`), + }; + return { + '.cursor-plugin/marketplace.json': { + name: marketplace.name, + owner: marketplace.owner, + metadata: marketplace.metadata, + plugins: [ + { + name: plugin.name, + source: { + source: 'git-subdir', + url: `${repository}.git`, + path: skills, + ref: 'master', + }, + description: plugin.description, + }, + ], + }, + [`${skills}/.cursor-plugin/plugin.json`]: { + ...listing, + version: '1.0.0', + category: 'developer-tools', + tags: ['react', 'vue', 'data-fetching'], + logo, + }, + // Claude's plugin directory submits this folder. No `version`: Claude Code + // then treats every commit as an update, like the root marketplace does. + [`${skills}/.claude-plugin/plugin.json`]: { + ...listing, + // listing fields only Claude's directory reads + icon: `./${logo}`, + documentationUrl: plugin.homepage, + supportUrl: `${repository}/issues`, + }, + }; +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + for (const [file, json] of Object.entries(pluginDirFiles())) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync( + path.join(root, file), + `${JSON.stringify(json, null, 2)}\n`, + ); + } +} diff --git a/.claude-plugin/cursor-plugin.test.mjs b/.claude-plugin/plugin-dir.test.mjs similarity index 54% rename from .claude-plugin/cursor-plugin.test.mjs rename to .claude-plugin/plugin-dir.test.mjs index 79a40c1d6858..dc0f60e8cd23 100644 --- a/.claude-plugin/cursor-plugin.test.mjs +++ b/.claude-plugin/plugin-dir.test.mjs @@ -1,18 +1,17 @@ -// The Cursor manifests are written by cursor.mjs, and marketplace.test.mjs -// checks they match it. This checks what they point at still installs: +// The plugin directory's manifests are written by plugin-dir.mjs, and +// marketplace.test.mjs checks they match it. This checks what they point at +// still installs: // node --test '.claude-plugin/*.test.mjs' import assert from 'node:assert/strict'; import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; -import { root } from './cursor.mjs'; +import { root } from './plugin-dir.mjs'; const pluginRoot = path.join(root, '.agents/skills'); -const manifest = JSON.parse( - fs.readFileSync(path.join(pluginRoot, '.cursor-plugin/plugin.json'), 'utf8'), -); const read = file => fs.readFileSync(path.join(pluginRoot, file), 'utf8'); +const manifest = JSON.parse(read('.cursor-plugin/plugin.json')); test('plugin skills are directories inside the plugin, named by SKILL.md', () => { for (const skill of manifest.skills) { @@ -32,6 +31,16 @@ test('plugin skills are directories inside the plugin, named by SKILL.md', () => }); test('logo stays inside the plugin directory', () => { - assert.equal(manifest.logo.includes('..'), false); - assert.ok(fs.existsSync(path.join(pluginRoot, manifest.logo))); + const { icon } = JSON.parse(read('.claude-plugin/plugin.json')); + for (const file of [manifest.logo, icon]) { + assert.equal(file.includes('..'), false, file); + assert.ok(fs.existsSync(path.join(pluginRoot, file)), file); + } +}); + +test('README is long enough for the Claude directory listing', () => { + // the directory shows it as the description and blocks under 40 words, + // not counting code blocks + const prose = read('README.md').replace(/```[\s\S]*?```/g, ''); + assert.ok(prose.split(/\s+/).filter(Boolean).length >= 40); }); diff --git a/.claude-plugin/readme-commands.test.mjs b/.claude-plugin/readme-commands.test.mjs index a4a854cba039..80cdcf0595a0 100644 --- a/.claude-plugin/readme-commands.test.mjs +++ b/.claude-plugin/readme-commands.test.mjs @@ -5,7 +5,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { test } from 'node:test'; -import { root } from './cursor.mjs'; +import { root } from './plugin-dir.mjs'; import { claudePluginCommand, claudeSetupPrompt, @@ -40,6 +40,17 @@ for (const [file, framework] of Object.entries({ }); } +// the plugin directory listings' description +test('.agents/skills/README.md plugin commands match skillCommands.mjs', () => { + const readme = read('.agents/skills/README.md'); + for (const command of [ + claudePluginCommand, + cursorPluginCommand, + codexPluginCommand, + ]) + assert.ok(readme.includes(command), command); +}); + // protocol READMEs carry only the prompt for (const pkg of ['rest', 'graphql', 'endpoint']) { test(`packages/${pkg}/README.md setup prompt matches skillCommands.mjs`, () => { diff --git a/.github/workflows/agent-rules.yml b/.github/workflows/agent-rules.yml index be98f46643ad..5cd40255ad53 100644 --- a/.github/workflows/agent-rules.yml +++ b/.github/workflows/agent-rules.yml @@ -68,3 +68,8 @@ jobs: run: node scripts/breaking-evals.mjs - name: Test agent hooks and the skill sync run: node --test '.cursor/hooks/*.test.js' '.claude-plugin/*.test.mjs' 'packages/core/agents/*.test.mjs' + # the folder Claude's plugin directory lists (.claude-plugin/README.md). + # The one step that downloads (claude-code from npm); pinned so a CLI + # release can't change the check without a PR bumping it. + - name: Validate the plugin directory + run: npx -y @anthropic-ai/claude-code@2.1.306 plugin validate ./.agents/skills diff --git a/.github/workflows/regenerate.yml b/.github/workflows/regenerate.yml index b3ace622ec00..37f57955e655 100644 --- a/.github/workflows/regenerate.yml +++ b/.github/workflows/regenerate.yml @@ -4,7 +4,7 @@ name: regenerate # Fails when the committed output is stale: PR authors regenerate and commit it. # Also type-checks the docs' React and Vue examples, and runs the agents' # browser debugging snippets in Chromium. -# Claude Code rules are left to agent-rules.yml, which needs no install. +# Claude Code rules are left to agent-rules.yml, which installs no dependencies. on: push: branches: diff --git a/.gitignore b/.gitignore index acff5beeb886..37c8f0b16306 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,4 @@ packages/*/skills/ # data-client-skills code copied in at pack time (from packages/core/agents) packages/*/agents/ !packages/core/agents/ +data-client-plugin.zip