diff --git a/tools/ai-tools/README.md b/tools/ai-tools/README.md new file mode 100644 index 0000000000..39b1339a4c --- /dev/null +++ b/tools/ai-tools/README.md @@ -0,0 +1,50 @@ +# AI tooling (Gamut) + +Internal home for **Cursor plugins**, **Claude Code plugins**, and related agent tooling. Contents under `examples/templates/` are **reference templates**—copy and adapt when starting something new; promote separate copies if they become production plugins. These are **not** published or distributed through Cursor or Claude Code marketplaces; they exist only as in-repo starting points. + +## Conventions + +- **Naming:** Use kebab-case for plugin directories and manifest `name` fields. +- **Review:** Treat changes like other shared engineering standards (PR review, clear purpose in the manifest `description`). +- **Scope:** Keep examples minimal; grow plugins in dedicated folders or repos when they need CI, tests, or release versioning. + +## Claude Code and these templates + +Use a copied template with **`--plugin-dir`** pointing at the plugin folder (for example `examples/templates/claude-plugin` while experimenting in this repo). See [Claude Code — Create plugins](https://code.claude.com/docs/en/plugins). + +We are **not** planning to distribute these templates through a plugin marketplace. If you later publish your own plugin, see Anthropic’s docs for [plugin marketplaces](https://code.claude.com/docs/en/plugin-marketplaces); that is separate from these Gamut reference templates. + +The Claude template includes a minimal `.claude-plugin/marketplace.json` **only** so the optional Gamut `install-plugin` helper can resolve a local `plugin@marketplace` spec when registering this directory with the Claude CLI on your machine. It does **not** imply publishing or listing these templates anywhere. + +## Validate manifests + +From the repository root: + +```bash +npx nx run ai-tools:validate +``` + +This parses each example `plugin.json` so broken JSON is caught early. + +## Further reading + +### Official documentation + +- [Cursor — Plugins reference](https://cursor.com/docs/reference/plugins) +- [Claude Code — Create plugins](https://code.claude.com/docs/en/plugins) +- [Claude Code — Plugins reference (Anthropic docs)](https://docs.anthropic.com/en/docs/claude-code/plugins-reference) +- [Nx — Introduction](https://nx.dev/docs/getting-started/intro) +- [Nx — Crafting your workspace](https://nx.dev/docs/getting-started/tutorials/crafting-your-workspace) + +### Reference repositories + +- [cursor/plugins](https://github.com/cursor/plugins) — Spec, marketplace layout, multiple plugins in one repository +- [cursor/plugin-template](https://github.com/cursor/plugin-template) — Scaffold for single- and multi-plugin repos +- [planetscale/cursor-plugin](https://github.com/planetscale/cursor-plugin) — Third-party plugin example (skills, MCP-oriented patterns) +- [anthropics/claude-plugins-official](https://github.com/anthropics/claude-plugins-official) — Official Claude Code plugin catalog (`plugins/`, `external_plugins/`) +- [anthropics/claude-plugins-official — example-plugin](https://github.com/anthropics/claude-plugins-official/tree/main/plugins/example-plugin) — Reference plugin layout +- [modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) — MCP server reference implementations + +### Monorepo structure + +- [Nx — Virtuous cycle of workspace structure](https://nx.dev/blog/virtuous-cycle-of-workspace-structure) diff --git a/tools/ai-tools/agent-manager/README.md b/tools/ai-tools/agent-manager/README.md new file mode 100644 index 0000000000..9f17564fda --- /dev/null +++ b/tools/ai-tools/agent-manager/README.md @@ -0,0 +1,3 @@ +# agent-manager (vendored) + +Local copy of the web-platform CLI used by `yarn install-plugin` at the Gamut repo root. Canonical source and history: `experiments/agent-manager` in the web-platform repository. diff --git a/tools/ai-tools/agent-manager/package.json b/tools/ai-tools/agent-manager/package.json new file mode 100644 index 0000000000..659577286b --- /dev/null +++ b/tools/ai-tools/agent-manager/package.json @@ -0,0 +1,16 @@ +{ + "name": "agent-manager", + "version": "0.0.1", + "description": "A command-line helper to manage agent tooling.", + "type": "module", + "main": "src/index.ts", + "scripts": { + "typecheck": "tsc -p tsconfig.json" + }, + "dependencies": { + "@cliffy/command": "npm:@jsr/cliffy__command@^1.0.0" + }, + "devDependencies": { + "@types/node": "^25.5.0" + } +} diff --git a/tools/ai-tools/agent-manager/src/commands/install.ts b/tools/ai-tools/agent-manager/src/commands/install.ts new file mode 100644 index 0000000000..9359541677 --- /dev/null +++ b/tools/ai-tools/agent-manager/src/commands/install.ts @@ -0,0 +1,193 @@ +import { Command, EnumType } from '@cliffy/command'; +import { spawn } from 'node:child_process'; +import { cp, mkdir, readFile, rm, stat, symlink } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { basename, join, resolve as resolvePath } from 'node:path'; +import process from 'node:process'; + +const CURSOR_INSTALL_METHOD = process.env.CURSOR_INSTALL_METHOD ?? 'copy'; + +export type Agent = 'cursor' | 'claude'; + +type MarketplaceJson = { + name?: string; + plugins?: { name?: string; source?: string }[]; +}; + +function expandUserPath(raw: string): string { + if (raw === '~') { + return homedir(); + } + if (raw.startsWith('~/')) { + return join(homedir(), raw.slice(2)); + } + return raw; +} + +async function resolvePluginSource(raw: string): Promise { + const expanded = expandUserPath(raw); + const root = resolvePath(expanded); + const st = await stat(root).catch(() => undefined); + if (!st?.isDirectory()) { + throw new Error(`Source is not a directory: ${raw} → ${root}`); + } + return root; +} + +async function cursorDestFolderName(sourceRoot: string): Promise { + const cursorManifest = join(sourceRoot, '.cursor-plugin', 'plugin.json'); + try { + const text = await readFile(cursorManifest, 'utf8'); + const j = JSON.parse(text) as { name?: string }; + if (j.name && typeof j.name === 'string') { + return j.name.replace(/^@/, '').replace(/\//g, '-'); + } + } catch { + /* no manifest */ + } + return basename(sourceRoot); +} + +async function claudePluginSpecFromMarketplace( + sourceRoot: string +): Promise { + const mp = join(sourceRoot, '.claude-plugin', 'marketplace.json'); + let text: string; + try { + text = await readFile(mp, 'utf8'); + } catch { + throw new Error( + `Missing ${mp}. For Claude Code, add a local marketplace file (see https://code.claude.com/docs/en/plugin-marketplaces ) or use: claude --plugin-dir ${sourceRoot}` + ); + } + const { name: marketplaceName, plugins } = JSON.parse( + text + ) as MarketplaceJson; + if (!marketplaceName || !Array.isArray(plugins) || plugins.length === 0) { + throw new Error( + `Invalid marketplace.json (need name and plugins[]): ${mp}` + ); + } + const entry = + plugins.find( + (p) => p.source === './' || p.source === '.' || p.source === undefined + ) ?? plugins[0]; + const pluginName = entry?.name; + if (!pluginName) { + throw new Error(`No plugin name in marketplace.json plugins[]: ${mp}`); + } + return `${pluginName}@${marketplaceName}`; +} + +function runCommand(command: string, args: string[]): Promise { + return new Promise((resolveCode, reject) => { + const child = spawn(command, args, { stdio: 'inherit', shell: false }); + child.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'ENOENT') { + reject(new Error(`${command} not found on PATH.`)); + } else { + reject(err); + } + }); + child.on('close', (code) => resolveCode(code ?? 1)); + }); +} + +/** + * Registers the plugin folder as a local marketplace and installs into Claude’s plugin cache + * (see https://code.claude.com/docs/en/plugin-marketplaces ). + */ +async function installClaudeCode( + sourceRoot: string, + pluginSpec: string +): Promise { + const root = resolvePath(sourceRoot); + const marketplaceName = pluginSpec.split('@')[1]; + if (!marketplaceName) { + throw new Error(`Invalid plugin spec: ${pluginSpec}`); + } + + let code = await runCommand('claude', [ + 'plugin', + 'marketplace', + 'add', + root, + '--scope', + 'user', + ]); + if (code !== 0) { + process.stderr.write( + `warning: claude plugin marketplace add exited ${code} (if the marketplace is already registered, you can ignore this).\n` + ); + code = await runCommand('claude', [ + 'plugin', + 'marketplace', + 'update', + marketplaceName, + ]); + if (code !== 0) { + throw new Error( + `claude plugin marketplace add/update failed (${code}). Try: claude plugin marketplace add ${root}` + ); + } + } + + code = await runCommand('claude', [ + 'plugin', + 'install', + pluginSpec, + '--scope', + 'user', + ]); + if (code !== 0) { + throw new Error( + `claude plugin install failed (${code}). Try: claude plugin install ${pluginSpec} --scope user` + ); + } + + process.stdout.write( + `Claude Code: installed ${pluginSpec} (user scope). Run /reload-plugins in Claude if needed.\n` + ); + process.stdout.write( + `One-off without install: claude --plugin-dir ${root} (https://code.claude.com/docs/en/plugins )\n` + ); +} + +async function installCursor( + sourceRoot: string, + destFolder: string +): Promise { + const home = homedir(); + const destRoot = + process.env.CURSOR_PLUGINS_LOCAL ?? + join(home, '.cursor', 'plugins', 'local'); + const dest = join(destRoot, destFolder); + await mkdir(destRoot, { recursive: true }); + await rm(dest, { recursive: true, force: true }); + if (CURSOR_INSTALL_METHOD === 'copy') { + await cp(sourceRoot, dest, { recursive: true }); + } else { + await symlink(sourceRoot, dest, 'dir'); + } + process.stdout.write(`Cursor plugin installed to ${dest}\n`); +} + +const installCmd = new Command() + .description('Install local agent tools.') + .arguments('') + .type('agent', new EnumType(['cursor', 'claude'])) + .option('-a, --agent ', 'Target agent.', { default: 'cursor' }) + .action(async ({ agent }, source: string) => { + const src = await resolvePluginSource(source); + if (agent === 'claude') { + const spec = await claudePluginSpecFromMarketplace(src); + return installClaudeCode(src, spec); + } + if (agent === 'cursor') { + const destFolder = await cursorDestFolderName(src); + return installCursor(src, destFolder); + } + throw new Error(`Unknown agent: ${agent}`); + }); + +export default installCmd; diff --git a/tools/ai-tools/agent-manager/src/index.ts b/tools/ai-tools/agent-manager/src/index.ts new file mode 100644 index 0000000000..d725dc65b9 --- /dev/null +++ b/tools/ai-tools/agent-manager/src/index.ts @@ -0,0 +1,23 @@ +#! /usr/bin/env tsx + +import { Command } from '@cliffy/command'; +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; + +import installCmd from './commands/install.js'; + +const pkg = JSON.parse( + await readFile( + join(dirname(fileURLToPath(import.meta.url)), '..', 'package.json'), + 'utf8' + ) +) as { version: string; description: string }; + +await new Command() + .name('agent-manager') + .version(pkg.version) + .description(pkg.description) + .command('install', installCmd) + .parse(process.argv.slice(2)); diff --git a/tools/ai-tools/agent-manager/tsconfig.json b/tools/ai-tools/agent-manager/tsconfig.json new file mode 100644 index 0000000000..6fc6e582d4 --- /dev/null +++ b/tools/ai-tools/agent-manager/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "verbatimModuleSyntax": true, + "noEmit": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"] +} diff --git a/tools/ai-tools/examples/templates/claude-plugin/.claude-plugin/marketplace.json b/tools/ai-tools/examples/templates/claude-plugin/.claude-plugin/marketplace.json new file mode 100644 index 0000000000..544751d64d --- /dev/null +++ b/tools/ai-tools/examples/templates/claude-plugin/.claude-plugin/marketplace.json @@ -0,0 +1,9 @@ +{ + "name": "gamut-ai-tools-templates", + "plugins": [ + { + "name": "claude-plugin-template", + "source": "./" + } + ] +} diff --git a/tools/ai-tools/examples/templates/claude-plugin/.claude-plugin/plugin.json b/tools/ai-tools/examples/templates/claude-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000000..9ebf14124c --- /dev/null +++ b/tools/ai-tools/examples/templates/claude-plugin/.claude-plugin/plugin.json @@ -0,0 +1,9 @@ +{ + "name": "claude-plugin-template", + "description": "Minimal example Claude Code plugin for the Gamut repo. Replace name and metadata when copying.", + "version": "0.0.0", + "author": { + "name": "Codecademy Engineers", + "email": "dev@codecademy.com" + } +} diff --git a/tools/ai-tools/examples/templates/claude-plugin/skills/example-template/SKILL.md b/tools/ai-tools/examples/templates/claude-plugin/skills/example-template/SKILL.md new file mode 100644 index 0000000000..c7ec7ca5b7 --- /dev/null +++ b/tools/ai-tools/examples/templates/claude-plugin/skills/example-template/SKILL.md @@ -0,0 +1,8 @@ +--- +name: example-template +description: Placeholder skill for the Gamut Claude Code plugin template; replace when authoring a real skill. +--- + +# Example skill + +Use `skills//SKILL.md` with frontmatter `name` and `description`. Add optional scripts or references alongside this file as needed. diff --git a/tools/ai-tools/examples/templates/cursor-plugin/.cursor-plugin/plugin.json b/tools/ai-tools/examples/templates/cursor-plugin/.cursor-plugin/plugin.json new file mode 100644 index 0000000000..e0ceac6d85 --- /dev/null +++ b/tools/ai-tools/examples/templates/cursor-plugin/.cursor-plugin/plugin.json @@ -0,0 +1,14 @@ +{ + "name": "cursor-plugin-template", + "displayName": "Cursor plugin template (Gamut)", + "version": "0.0.0", + "description": "Minimal example Cursor plugin for the Gamut repo. Replace name and paths when copying.", + "author": { + "name": "Codecademy Engineers", + "email": "dev@codecademy.com" + }, + "license": "MIT", + "keywords": ["gamut", "example", "template"], + "rules": "./rules/", + "skills": "./skills/" +} diff --git a/tools/ai-tools/examples/templates/cursor-plugin/rules/example.mdc b/tools/ai-tools/examples/templates/cursor-plugin/rules/example.mdc new file mode 100644 index 0000000000..4f1ade302b --- /dev/null +++ b/tools/ai-tools/examples/templates/cursor-plugin/rules/example.mdc @@ -0,0 +1,8 @@ +--- +description: Example project rule for the Cursor plugin template (replace with real guidance). +alwaysApply: false +--- + +# Example rule + +This file demonstrates where **rules** live in a Cursor plugin. Replace this content with conventions relevant to your project. diff --git a/tools/ai-tools/examples/templates/cursor-plugin/skills/example-template/SKILL.md b/tools/ai-tools/examples/templates/cursor-plugin/skills/example-template/SKILL.md new file mode 100644 index 0000000000..61b7219d3b --- /dev/null +++ b/tools/ai-tools/examples/templates/cursor-plugin/skills/example-template/SKILL.md @@ -0,0 +1,8 @@ +--- +name: example-template +description: Placeholder skill for the Gamut Cursor plugin template; replace when authoring a real skill. +--- + +# Example skill + +Use this folder layout for skills: `skills//SKILL.md` with frontmatter `name` and `description`. diff --git a/tools/ai-tools/project.json b/tools/ai-tools/project.json new file mode 100644 index 0000000000..6d80067c0b --- /dev/null +++ b/tools/ai-tools/project.json @@ -0,0 +1,16 @@ +{ + "name": "ai-tools", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/ai-tools", + "projectType": "library", + "tags": ["scope:ai-tooling"], + "targets": { + "validate": { + "executor": "nx:run-commands", + "options": { + "commands": ["node tools/ai-tools/scripts/validate-manifests.mjs"], + "parallel": false + } + } + } +} diff --git a/tools/ai-tools/scripts/validate-manifests.mjs b/tools/ai-tools/scripts/validate-manifests.mjs new file mode 100644 index 0000000000..c4d62a0cdc --- /dev/null +++ b/tools/ai-tools/scripts/validate-manifests.mjs @@ -0,0 +1,23 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const root = path.resolve(__dirname, '..'); + +const manifests = [ + path.join( + root, + 'examples/templates/cursor-plugin/.cursor-plugin/plugin.json' + ), + path.join( + root, + 'examples/templates/claude-plugin/.claude-plugin/plugin.json' + ), +]; + +for (const file of manifests) { + const text = fs.readFileSync(file, 'utf8'); + JSON.parse(text); + process.stdout.write(`OK ${path.relative(process.cwd(), file)}\n`); +}