Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions tools/ai-tools/README.md
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions tools/ai-tools/agent-manager/README.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 16 additions & 0 deletions tools/ai-tools/agent-manager/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
193 changes: 193 additions & 0 deletions tools/ai-tools/agent-manager/src/commands/install.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string> {
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<string> {
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<number> {
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<void> {
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<void> {
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('<source:string>')
.type('agent', new EnumType(['cursor', 'claude']))
.option('-a, --agent <name: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;
23 changes: 23 additions & 0 deletions tools/ai-tools/agent-manager/src/index.ts
Original file line number Diff line number Diff line change
@@ -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));
13 changes: 13 additions & 0 deletions tools/ai-tools/agent-manager/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"skipLibCheck": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src/**/*.ts"]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "gamut-ai-tools-templates",
"plugins": [
{
"name": "claude-plugin-template",
"source": "./"
}
]
}
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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-name>/SKILL.md` with frontmatter `name` and `description`. Add optional scripts or references alongside this file as needed.
Original file line number Diff line number Diff line change
@@ -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/"
}
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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-name>/SKILL.md` with frontmatter `name` and `description`.
16 changes: 16 additions & 0 deletions tools/ai-tools/project.json
Original file line number Diff line number Diff line change
@@ -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
}
}
}
}
Loading
Loading