diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c9cf3de..4a0fbe1 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,8 @@ { "name": "unity", "source": "./", - "description": "Unity's official game development plugin. Build, monetize, and operate Unity games with guidance grounded in Unity's documented practices." + "description": "Unity's official game development plugin. Build, monetize, and operate Unity games with guidance grounded in Unity's documented practices.", + "category": "Developer Tools" } ] } diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index a7df8ed..5097208 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,5 +1,6 @@ { "name": "unity", + "displayName": "Unity", "version": "0.1.8-beta", "description": "Unity's official plugin for coding agents, with curated skills for game development, monetization, and performance optimization.", "author": { @@ -7,6 +8,7 @@ "url": "https://unity.com" }, "homepage": "https://unity.com", + "repository": "https://github.com/Unity-Technologies/unity-agent-plugin", "license": "LicenseRef-Unity-Companion-License", "keywords": [ "unity", @@ -21,5 +23,10 @@ "localization", "multiplayer", "shader-graph" - ] + ], + "icon": "./assets/unity-tile.svg", + "documentationUrl": "https://github.com/Unity-Technologies/unity-agent-plugin#readme", + "supportUrl": "https://github.com/Unity-Technologies/unity-agent-plugin/issues", + "privacyPolicyUrl": "https://unity.com/legal/privacy-policy", + "termsOfServiceUrl": "https://unity.com/legal/terms-of-service" } diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index da8c81a..1bc5e8d 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -7,6 +7,7 @@ "url": "https://unity.com" }, "homepage": "https://unity.com", + "repository": "https://github.com/Unity-Technologies/unity-agent-plugin", "license": "LicenseRef-Unity-Companion-License", "keywords": [ "unity", @@ -45,7 +46,6 @@ "Set up a tilemap level with rule tiles for my 2D Unity game.", "Help me implement in-app purchases and set up ad mediation with LevelPlay.", "Set up multiplayer lobbies and networking for my Unity game." - ], - "screenshots": [] + ] } } diff --git a/.github/workflows/validate-manifests.yml b/.github/workflows/validate-manifests.yml new file mode 100644 index 0000000..ada8e15 --- /dev/null +++ b/.github/workflows/validate-manifests.yml @@ -0,0 +1,37 @@ +name: Validate manifests + +on: + pull_request: + paths: + - '.claude-plugin/**' + - '.codex-plugin/**' + - '.antigravity-plugin/**' + - '.agents/**' + - '.github/README.md' + - 'scripts/plugin-metadata.json' + - 'scripts/*.mts' + - 'scripts/tsconfig.json' + - '.github/workflows/validate-manifests.yml' + push: + branches: [main] + paths: + - '.claude-plugin/**' + - '.codex-plugin/**' + - '.antigravity-plugin/**' + - '.agents/**' + - 'scripts/plugin-metadata.json' + +jobs: + shared-values: + name: Manifests share one set of values + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: Typecheck the scripts + run: | + npm install --no-save --no-audit --no-fund typescript@5 @types/node@24 + npx tsc -p scripts/tsconfig.json + - run: node scripts/sync-manifests.mts --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/scripts/build-codex-zip.mts b/scripts/build-codex-zip.mts new file mode 100644 index 0000000..2204a8c --- /dev/null +++ b/scripts/build-codex-zip.mts @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// Builds the zip uploaded to OpenAI's plugin portal for a Codex release. +// +// node scripts/build-codex-zip.mts writes dist/unity-codex-.zip +// +// Run it on a clean checkout of merged main, after the version bump. The zip holds one folder, +// `unity`, with the skills (no evals/ or tests/), both manifests, the README, the license and +// the logos the manifests reference. It refuses to build if the manifests disagree on +// the version or drift from scripts/plugin-metadata.json. +import { execFileSync } from 'node:child_process'; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, join, resolve } from 'node:path'; + +const root = resolve(import.meta.dirname, '..'); +const meta: { version: string } = JSON.parse( + readFileSync(join(root, 'scripts/plugin-metadata.json'), 'utf8'), +); + +// 1. The manifests must already carry the release version. +execFileSync(process.execPath, [join(root, 'scripts/sync-manifests.mts'), '--check'], { + cwd: root, + stdio: 'inherit', +}); +const manifests = ['.codex-plugin/plugin.json', '.claude-plugin/plugin.json']; +for (const file of manifests) { + const { version } = JSON.parse(readFileSync(join(root, file), 'utf8')) as { version: string }; + if (version !== meta.version) fail(`${file} is at ${version}, expected ${meta.version}`); +} + +// 2. Stage the package as /unity. +const excluded = new Set(['evals', 'tests', '.DS_Store', 'Thumbs.db']); +const contents = [ + 'skills', + '.codex-plugin', + '.claude-plugin', + 'README.md', + 'LICENSE.md', + 'assets/unity.svg', + 'assets/unity-small.svg', + 'assets/unity-tile.svg', // the icon .claude-plugin/plugin.json points to +]; +const stage = mkdtempSync(join(tmpdir(), 'unity-codex-')); +const pkg = join(stage, 'unity'); +try { + for (const entry of contents) { + const from = join(root, entry); + if (!existsSync(from)) fail(`missing ${entry}`); + cpSync(from, join(pkg, entry), { + recursive: true, + filter: (src) => !excluded.has(basename(src)), + }); + } + + // 3. Check what was staged before zipping it. + const skills = readdirSync(join(pkg, 'skills')).filter((d) => + existsSync(join(pkg, 'skills', d, 'SKILL.md')), + ); + const codex = JSON.parse(readFileSync(join(pkg, '.codex-plugin/plugin.json'), 'utf8')) as { + interface?: { logo?: string; composerIcon?: string }; + }; + const assets = [codex.interface?.logo, codex.interface?.composerIcon]; + for (const asset of assets.filter((a): a is string => typeof a === 'string')) { + if (!existsSync(join(pkg, asset))) fail(`the Codex manifest references ${asset}, which is not in the package`); + } + + // 4. Zip it. Windows and macOS ship bsdtar, which writes zip archives; Linux uses zip. + const dist = join(root, 'dist'); + mkdirSync(dist, { recursive: true }); + const out = join(dist, `unity-codex-${meta.version}.zip`); + rmSync(out, { force: true }); + if (process.platform === 'linux') { + execFileSync('zip', ['-r', '-X', '-q', out, 'unity'], { cwd: stage, stdio: 'inherit' }); + } else { + const tar = + process.platform === 'win32' ? join(process.env.SystemRoot ?? 'C:\Windows', 'System32', 'tar.exe') : 'tar'; + execFileSync(tar, ['--format', 'zip', '-cf', out, 'unity'], { cwd: stage, stdio: 'inherit' }); + } + + console.log(`Built ${out}`); + console.log(` version ${meta.version}, ${skills.length} skills, ${(statSync(out).size / 1024).toFixed(0)} KB`); + console.log('Next: upload it at https://platform.openai.com/plugins (Upload plugin to make changes).'); +} finally { + rmSync(stage, { recursive: true, force: true }); +} + +function fail(message: string): never { + console.error(`build-codex-zip: ${message}`); + process.exit(1); +} diff --git a/scripts/plugin-metadata.json b/scripts/plugin-metadata.json new file mode 100644 index 0000000..0d3576c --- /dev/null +++ b/scripts/plugin-metadata.json @@ -0,0 +1,25 @@ +{ + "version": "0.1.8-beta", + "author": { + "name": "Unity Technologies", + "url": "https://unity.com" + }, + "homepage": "https://unity.com", + "repository": "https://github.com/Unity-Technologies/unity-agent-plugin", + "license": "LicenseRef-Unity-Companion-License", + "category": "Developer Tools", + "keywords": [ + "unity", + "gamedev", + "game-development", + "ui-toolkit", + "ugui", + "2d", + "tilemap", + "in-app-purchases", + "monetization", + "localization", + "multiplayer", + "shader-graph" + ] +} diff --git a/scripts/sync-manifests.mts b/scripts/sync-manifests.mts new file mode 100644 index 0000000..78167ce --- /dev/null +++ b/scripts/sync-manifests.mts @@ -0,0 +1,127 @@ +#!/usr/bin/env node +// Keeps the values every platform manifest shares (version, author, homepage, repository, +// license, keywords, category) in one place: scripts/plugin-metadata.json. +// +// node scripts/sync-manifests.mts write the shared values into every manifest +// node scripts/sync-manifests.mts --check fail if any manifest has drifted (CI runs this) +// +// To release, change "version" in plugin-metadata.json and run the script without --check. +// Platform-only fields (descriptions, the Codex interface block, Antigravity prompts) stay in +// each manifest and are not touched. +import { readFileSync, writeFileSync } from 'node:fs'; + +type Json = string | number | boolean | null | Json[] | { [key: string]: Json }; +type JsonObject = { [key: string]: Json }; + +interface Metadata { + version: string; + author: { name: string; url?: string; email?: string }; + homepage: string; + repository: string; + license: string; + category: string; + keywords: string[]; +} + +interface Marketplace extends JsonObject { + plugins: JsonObject[]; +} + +const check = process.argv.includes('--check'); +const meta: Metadata = JSON.parse(readFileSync('scripts/plugin-metadata.json', 'utf8')); +const shared = ['version', 'author', 'homepage', 'repository', 'license', 'keywords'] as const; + +// Each target names a manifest and where each shared key lives in it. +const targets: { file: string; set: (manifest: JsonObject) => void }[] = [ + { file: '.claude-plugin/plugin.json', set: (m) => assign(m, shared) }, + { + file: '.codex-plugin/plugin.json', + set: (m) => { + assign(m, shared); + (m.interface as JsonObject).category = meta.category; + }, + }, + { + // No category: agy 1.4 reports it as an unknown field, which fails `validate --strict`. + file: '.antigravity-plugin/plugin.json', + set: (m) => { + assign(m, shared); + delete m.category; + }, + }, + { + // The entry must not carry a version: plugin.json wins at install, and validate warns. + file: '.claude-plugin/marketplace.json', + set: (m) => { + const entry = pluginEntry(m as Marketplace); + delete entry.version; + entry.category = meta.category; + }, + }, + { + file: '.agents/plugins/marketplace.json', + set: (m) => { + pluginEntry(m as Marketplace).category = meta.category; + }, + }, +]; + +// Where a key goes when a manifest doesn't have it yet, so new keys sit beside related ones. +const anchors: Record = { repository: 'homepage', license: 'repository' }; + +function assign(manifest: JsonObject, keys: readonly (keyof Metadata)[]): void { + for (const key of keys) setKey(manifest, key, structuredClone(meta[key])); +} + +function setKey(obj: JsonObject, key: string, value: Json): void { + const anchor = anchors[key]; + if (key in obj || !anchor || !(anchor in obj)) { + obj[key] = value; + return; + } + const entries = Object.entries(obj); + const at = entries.findIndex(([k]) => k === anchor) + 1; + entries.splice(at, 0, [key, value]); + for (const k of Object.keys(obj)) delete obj[k]; + Object.assign(obj, Object.fromEntries(entries)); +} + +function pluginEntry(marketplace: Marketplace): JsonObject { + const entry = marketplace.plugins?.find((p) => p.name === 'unity'); + if (!entry) throw new Error('no "unity" entry in plugins[]'); + return entry; +} + +const drifted: string[] = []; +for (const { file, set } of targets) { + const before = readFileSync(file, 'utf8').replace(/\r\n/g, '\n'); + const manifest = JSON.parse(before); + set(manifest); + const after = `${JSON.stringify(manifest, null, 2)}\n`; + if (after === before) continue; + if (check) drifted.push(file); + else writeFileSync(file, after); +} + +// The sample `agy plugin list` and `codex plugin list` output in the README shows the version. +const readme = '.github/README.md'; +const readmeText = readFileSync(readme, 'utf8'); +const stale = [...readmeText.matchAll(/\b0\.\d+\.\d+(?:-[a-z0-9.]+)?\b/g)] + .map((m) => m[0]) + .filter((v) => v !== meta.version); +if (stale.length > 0) { + if (check) drifted.push(`${readme} (shows ${[...new Set(stale)].join(', ')})`); + else writeFileSync(readme, readmeText.replaceAll(new RegExp(stale.map(escape).join('|'), 'g'), meta.version)); +} + +function escape(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +if (drifted.length > 0) { + console.error('These files differ from scripts/plugin-metadata.json:'); + for (const file of drifted) console.error(` ${file}`); + console.error('Run `node scripts/sync-manifests.mts` and commit the result.'); + process.exit(1); +} +console.log(check ? 'Manifests match scripts/plugin-metadata.json' : 'Manifests updated'); diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..3b6b4b4 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2023", + "module": "nodenext", + "moduleResolution": "nodenext", + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "erasableSyntaxOnly": true, + "types": ["node"], + "skipLibCheck": true + }, + "include": ["*.mts"] +}