diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000000..127ba2f874 --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "gstack-local", + "interface": { + "displayName": "gstack Local Plugins" + }, + "plugins": [ + { + "name": "gstack", + "source": { + "source": "local", + "path": "./plugins/gstack" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Coding" + } + ] +} diff --git a/.gitignore b/.gitignore index 979bc17c73..9b23488ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,10 @@ bin/gstack-global-discover .claude/skills/ .claude/scheduled_tasks.lock .claude/*.lock -.agents/ +.agents/* +.agents/skills/ +!.agents/plugins/ +!.agents/plugins/** .factory/ .kiro/ .opencode/ diff --git a/README.md b/README.md index 426c8468f3..f7bd496d94 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,20 @@ Or target a specific agent with `./setup --host `: **Want to add support for another agent?** See [docs/ADDING_A_HOST.md](docs/ADDING_A_HOST.md). It's one TypeScript config file, zero code changes. +### Codex plugin marketplace (repo-local) + +If you want Codex to install gstack from this repository through a local plugin +marketplace instead of `~/.codex/skills`, run: + +```bash +bun run plugin:codex:prepare +``` + +That command regenerates the Codex-formatted skills in `.agents/skills/`, +refreshes the repo-local runtime sidecar under `.agents/skills/gstack/`, and +keeps `plugins/gstack` pointed at the generated skill tree. Codex can then load +the plugin from `.agents/plugins/marketplace.json`. + ## See it work ``` diff --git a/package.json b/package.json index ba1b4f8f8c..de29439bbd 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "eval:watch": "bun run scripts/eval-watch.ts", "eval:select": "bun run scripts/eval-select.ts", "analytics": "bun run scripts/analytics.ts", + "plugin:codex:prepare": "bun run scripts/prepare-codex-plugin.ts", "test:audit": "bun test test/audit-compliance.test.ts", "slop": "npx slop-scan scan . 2>/dev/null || echo 'slop-scan not available (install with: npm i -g slop-scan)'", "slop:diff": "bun run scripts/slop-diff.ts" diff --git a/plugins/gstack/.codex-plugin/plugin.json b/plugins/gstack/.codex-plugin/plugin.json new file mode 100644 index 0000000000..c5b28e24a0 --- /dev/null +++ b/plugins/gstack/.codex-plugin/plugin.json @@ -0,0 +1,43 @@ +{ + "name": "gstack", + "version": "1.21.1.0", + "description": "Garry's Stack \u2014 AI engineering workflows, QA, planning, and browser automation skills for Codex.", + "author": { + "name": "Garry Tan", + "email": "opensource@garrytan.com", + "url": "https://github.com/garrytan" + }, + "homepage": "https://github.com/garrytan/gstack", + "repository": "https://github.com/garrytan/gstack", + "license": "MIT", + "keywords": [ + "skills", + "codex", + "browser-automation", + "qa", + "planning", + "workflow" + ], + "skills": "./skills/", + "interface": { + "displayName": "gstack", + "shortDescription": "AI engineering workflows for Codex", + "longDescription": "Use gstack to add planning, QA, browser automation, review, deployment, and product workflow skills to Codex from this repository.", + "developerName": "Garry Tan", + "category": "Coding", + "capabilities": [ + "Interactive", + "Read", + "Write" + ], + "websiteURL": "https://github.com/garrytan/gstack", + "privacyPolicyURL": "https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement", + "termsOfServiceURL": "https://docs.github.com/en/site-policy/github-terms/github-terms-of-service", + "defaultPrompt": [ + "Run /gstack-qa against my local app and fix the bugs it finds.", + "Review this branch before I open a PR.", + "Help me plan a new feature before I start coding." + ], + "brandColor": "#2563EB" + } +} diff --git a/plugins/gstack/skills b/plugins/gstack/skills new file mode 120000 index 0000000000..bf44df0e59 --- /dev/null +++ b/plugins/gstack/skills @@ -0,0 +1 @@ +../../.agents/skills \ No newline at end of file diff --git a/scripts/prepare-codex-plugin.ts b/scripts/prepare-codex-plugin.ts new file mode 100644 index 0000000000..e465700335 --- /dev/null +++ b/scripts/prepare-codex-plugin.ts @@ -0,0 +1,80 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const repoRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))); +const agentsSkillsRoot = path.join(repoRoot, '.agents', 'skills'); +const runtimeRoot = path.join(agentsSkillsRoot, 'gstack'); +const pluginSkillsPath = path.join(repoRoot, 'plugins', 'gstack', 'skills'); + +function run(command: string[], cwd = repoRoot): void { + const result = spawnSync(command[0], command.slice(1), { + cwd, + stdio: 'inherit', + }); + if (result.status !== 0) { + process.exit(result.status ?? 1); + } +} + +function ensureRelativeSymlink(targetPath: string, linkPath: string): void { + const relativeTarget = path.relative(path.dirname(linkPath), targetPath); + const existing = fs.existsSync(linkPath) || fs.lstatSync(linkPath, { throwIfNoEntry: false }) !== undefined; + + if (existing) { + const stat = fs.lstatSync(linkPath); + if (stat.isSymbolicLink() && fs.readlinkSync(linkPath) === relativeTarget) { + return; + } + if (stat.isDirectory() && !stat.isSymbolicLink()) { + const entries = fs.readdirSync(linkPath); + if (entries.length > 0) { + throw new Error(`Refusing to replace non-empty directory: ${path.relative(repoRoot, linkPath)}`); + } + fs.rmdirSync(linkPath); + } else { + fs.rmSync(linkPath, { force: true, recursive: true }); + } + } + + fs.mkdirSync(path.dirname(linkPath), { recursive: true }); + fs.symlinkSync(relativeTarget, linkPath); +} + +function topLevelSkillDirs(): string[] { + return fs + .readdirSync(repoRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .filter((name) => !name.startsWith('.')) + .filter((name) => fs.existsSync(path.join(repoRoot, name, 'SKILL.md'))) + .filter((name) => name !== 'plugins'); +} + +run(['bun', 'run', 'scripts/gen-skill-docs.ts', '--host', 'codex']); + +fs.mkdirSync(runtimeRoot, { recursive: true }); + +const runtimeEntries = new Set([ + 'bin', + 'browse', + 'design', + 'extension', + 'review', + 'qa', + 'make-pdf', + 'ETHOS.md', + 'VERSION', + ...topLevelSkillDirs(), +]); + +for (const entry of runtimeEntries) { + const source = path.join(repoRoot, entry); + if (!fs.existsSync(source)) continue; + const dest = path.join(runtimeRoot, entry); + if (entry === 'SKILL.md' || entry === 'agents') continue; + ensureRelativeSymlink(source, dest); +} + +ensureRelativeSymlink(agentsSkillsRoot, pluginSkillsPath); diff --git a/test/codex-plugin-compat.test.ts b/test/codex-plugin-compat.test.ts new file mode 100644 index 0000000000..c96001278c --- /dev/null +++ b/test/codex-plugin-compat.test.ts @@ -0,0 +1,45 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dir, '..'); + +test('gstack plugin manifest points Codex at the generated skills bundle', () => { + const pluginJson = JSON.parse( + fs.readFileSync(path.join(ROOT, 'plugins', 'gstack', '.codex-plugin', 'plugin.json'), 'utf8'), + ); + + expect(pluginJson.name).toBe('gstack'); + expect(pluginJson.skills).toBe('./skills/'); + expect(pluginJson.interface.displayName).toBe('gstack'); + expect(pluginJson.interface.category).toBe('Coding'); +}); + +test('repo-local marketplace publishes the gstack plugin entry', () => { + const marketplace = JSON.parse( + fs.readFileSync(path.join(ROOT, '.agents', 'plugins', 'marketplace.json'), 'utf8'), + ); + + expect(marketplace.name).toBe('gstack-local'); + expect(marketplace.plugins).toEqual([ + { + name: 'gstack', + source: { + source: 'local', + path: './plugins/gstack', + }, + policy: { + installation: 'AVAILABLE', + authentication: 'ON_INSTALL', + }, + category: 'Coding', + }, + ]); +}); + +test('plugin skills directory resolves to the generated Codex skill tree', () => { + const skillsPath = path.join(ROOT, 'plugins', 'gstack', 'skills'); + const stat = fs.lstatSync(skillsPath); + + expect(stat.isSymbolicLink()).toBe(true); + expect(fs.readlinkSync(skillsPath)).toBe('../../.agents/skills'); +});