From a20b8f61ae96a3c0edd39094c52e1bebcec70d98 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Wed, 4 Nov 2026 11:28:22 -0600 Subject: [PATCH] feat(codex): add codex plugin with oauth mcp sign-in Ship an OpenAI Codex plugin so Codex users install every Socket skill in one step and sign in to the hosted Socket MCP server with their Socket account instead of pasting one organization's API token. - scripts/repo/generate-codex-plugin.mts writes skills/.codex-plugin/plugin.json, skills/.codex-plugin/mcp.json, and .agents/plugins/marketplace.json from .claude-plugin/plugin.json. The plugin root is skills/, so a Codex install copies only skill content. Output matches the repo formatter byte for byte. - mcp.json reuses the shared `socket` server name and URL, with the server's advertised scopes and no bearer setting, so Codex runs OAuth (dynamic client registration + PKCE). - Register the generator in publish.mts, classify .agents as a shipped root entry, and re-include only .agents/plugins/marketplace.json in the repo .gitignore cutout. - Skill-dir test helpers skip dot dirs; manifest consistency covers the Codex manifest, marketplace, and server URL. - socket-connect: Codex sign-in row and tool-name example. - README Codex section: marketplace add, install, MCP login, CLI auth, and migration from single-skill plugins. --- .agents/plugins/marketplace.json | 20 ++ .gitignore | 5 +- README.md | 32 +- scripts/repo/constants/shipped-surfaces.mts | 1 + scripts/repo/generate-codex-plugin.mts | 322 ++++++++++++++++++ scripts/repo/paths.mts | 12 + scripts/repo/publish.mts | 5 + skills/.codex-plugin/mcp.json | 9 + skills/.codex-plugin/plugin.json | 38 +++ skills/socket-connect/SKILL.md | 16 +- test/repo/unit/generate-codex-plugin.test.mts | 240 +++++++++++++ .../paths-manifest-consistency.test.mts | 45 ++- .../paths-skill-content-quality.test.mts | 5 +- .../structural/paths-skill-discovery.test.mts | 10 +- 14 files changed, 743 insertions(+), 17 deletions(-) create mode 100644 .agents/plugins/marketplace.json create mode 100644 scripts/repo/generate-codex-plugin.mts create mode 100644 skills/.codex-plugin/mcp.json create mode 100644 skills/.codex-plugin/plugin.json create mode 100644 test/repo/unit/generate-codex-plugin.test.mts diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 00000000..c70a633e --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,20 @@ +{ + "name": "socket-skills", + "interface": { + "displayName": "Socket" + }, + "plugins": [ + { + "name": "socket-skills", + "source": { + "source": "local", + "path": "./skills" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Developer Tools" + } + ] +} diff --git a/.gitignore b/.gitignore index faf70ccd..440bbaea 100644 --- a/.gitignore +++ b/.gitignore @@ -31,7 +31,10 @@ node_modules/ /.claude/* !/.claude/settings.json -/.agents/ +/.agents/* +!/.agents/plugins/ +/.agents/plugins/* +!/.agents/plugins/marketplace.json /.codex/ /.cursor/ /.opencode/* diff --git a/README.md b/README.md index 7bf923f4..8c731e0e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Socket Security Skills are definitions for dependency security tasks like vulnerability scanning, package review, patching, firewall configuration, and secure dependency updates. They follow the standardized [Agent Skill](https://agentskills.io/home) format and are compatible with **40+ coding agent tools** including: - **Claude Code** (Anthropic) - native skill/plugin support -- **Codex** (OpenAI) - Agent Skills standard + AGENTS.md fallback +- **Codex** (OpenAI) - plugin with Socket account sign-in, Agent Skills standard, AGENTS.md fallback - **Gemini CLI** (Google DeepMind) - extensions support - **OpenCode** - skill directory support - **Cursor** - plugin with skills, rules, and the Socket MCP server @@ -68,11 +68,35 @@ The installable plugin names are `socket-connect`, `socket-setup`, `socket-scan` ### Codex -1. Copy or symlink any skills you want to use from this repository's `skills/` directory into one of Codex's standard `.agents/skills` locations (for example, `$REPO_ROOT/.agents/skills` or `$HOME/.agents/skills`) as described in the [Codex Skills guide](https://developers.openai.com/codex/skills/). +This repo is a Codex plugin marketplace. The `socket-skills` plugin bundles every skill and connects Codex to the hosted [Socket MCP server](https://mcp.socket.dev/). Codex signs in to the MCP server with your Socket account in the browser, so the MCP tools need no API token, and one sign-in covers every organization your account can see. The MCP tools look up package scores, alerts, the threat feed, and your organizations. -2. Once a skill is available in one of those locations, Codex will discover it using the Agent Skills standard and load the `SKILL.md` instructions when it decides to use that skill or when you explicitly invoke it. +1. Add the marketplace: -3. If your Codex setup still relies on `AGENTS.md`, you can use the generated [`agents/README.md`](agents/README.md) file in this repo as a fallback bundle of instructions - copy it into your project as `AGENTS.md`. + ```bash + codex plugin marketplace add SocketDev/skills + ``` + +1. Install the plugin. In the Codex TUI, run `/plugins`, select **Socket**, and install it. Codex opens your browser to sign in to Socket. From a shell, run: + + ```bash + codex plugin add socket-skills@socket-skills + ``` + +1. If you installed from a shell, or a Socket tool reports `Authentication required`, sign in to the MCP server: + + ```bash + codex mcp login socket + ``` + + On a machine without a browser, such as over SSH, run `codex mcp login socket --no-browser` and paste the redirect URL back into the terminal. In the TUI, run `/mcp login socket`. + +1. Start a new Codex session so it loads the plugin. + +The scan and fix skills also run the Socket CLI, which signs in separately with `socket login`; the `socket-connect` skill walks through it. The hosted MCP server does not accept API tokens, so CI and other non-interactive `codex exec` runs use the CLI with `SOCKET_CLI_API_TOKEN`. + +If you installed single-skill plugins such as `socket-scan@socket-skills` from this marketplace before, remove each one with `codex plugin remove @socket-skills` before you install `socket-skills`. Otherwise Codex keeps the old copies, stops updating them, and loads each skill twice. + +To use skills without the plugin, copy or symlink folders from `skills/` into a standard `.agents/skills` location, as described in the [Codex Skills guide](https://developers.openai.com/codex/skills/). If your Codex setup relies on `AGENTS.md`, copy the generated [`agents/README.md`](agents/README.md) into your project as `AGENTS.md`. ### Gemini CLI diff --git a/scripts/repo/constants/shipped-surfaces.mts b/scripts/repo/constants/shipped-surfaces.mts index 4f4621d9..fe3e6995 100644 --- a/scripts/repo/constants/shipped-surfaces.mts +++ b/scripts/repo/constants/shipped-surfaces.mts @@ -30,6 +30,7 @@ export const SHIPPED_INSTRUCTION_DIRS = ['.cursor-plugin/rules'] as const * consumer copies wholesale. */ export const SHIPPED_ROOT_FILES = [ + '.agents', '.claude-plugin', '.cursor-plugin', '.opencode', diff --git a/scripts/repo/generate-codex-plugin.mts b/scripts/repo/generate-codex-plugin.mts new file mode 100644 index 00000000..08476641 --- /dev/null +++ b/scripts/repo/generate-codex-plugin.mts @@ -0,0 +1,322 @@ +#!/usr/bin/env node +/** + * Generate OpenAI Codex plugin artifacts from .claude-plugin/plugin.json: + * + * - Skills/.codex-plugin/plugin.json — the plugin manifest. + * - Skills/.codex-plugin/mcp.json — the hosted Socket MCP server. Codex runs + * OAuth (dynamic client registration + PKCE) against it, so users sign in + * with their Socket account instead of pasting one org's API token. + * - .agents/plugins/marketplace.json — the repo marketplace Codex reads first. + * + * The plugin root is skills/, not the repo root: Codex copies the whole plugin + * root into its cache on install, and skills/ is the only shipped content Codex + * needs. The MCP artifact is never named .mcp.json — that name is the fleet's + * MCP inventory and is gitignored at every depth. This generator must never + * write outside skills/.codex-plugin/ and .agents/plugins/. + */ + +import { existsSync, readdirSync } from 'node:fs' +import * as path from 'node:path' +import { fileURLToPath } from 'node:url' +import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default' +import { isMainModule, runMain } from './util/main.mts' +import type { ScriptMeta } from './util/main.mts' +import { + repoClaudePluginPath, + repoCodexMarketplacePath, + repoCodexMcpPath, + repoCodexPluginPath, +} from './paths.mts' +import { + loadJson, + SOCKET_MCP_SERVER_NAME, + SOCKET_MCP_URL, + validatePluginName, + writeOrCheck, +} from './generate-cursor-plugin.mts' + +// One definition of the hosted server for every agent manifest. +export { SOCKET_MCP_SERVER_NAME, SOCKET_MCP_URL } + +const logger = getDefaultLogger() + +const ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', +) + +// Plugin-root-relative reference to the plugin's own mcp config artifact. +// Codex ignores manifest paths that do not start with './'. +const CODEX_MCP_RELATIVE = './.codex-plugin/mcp.json' + +// Marketplace-root-relative (repo-root-relative) plugin source. +const CODEX_PLUGIN_SOURCE = './skills' + +const CATEGORY = 'Developer Tools' + +const DISPLAY_NAME = 'Socket' + +// The scopes the hosted server advertises in its protected-resource metadata. +// Current Codex discovers them on its own; pinning them keeps older Codex +// builds from requesting the authorization server's full scope list. +export const SOCKET_MCP_SCOPES = [ + 'packages:list', + 'alerts:list', + 'threat-feed:list', +] as const + +const DEFAULT_PROMPTS = [ + "Scan this project's dependencies with Socket.", + 'Check an npm package with Socket before I add it.', + 'Fix the vulnerable dependencies in this project.', +] as const + +export interface CodexPluginOptions { + root?: string | undefined +} + +export interface GenerateCodexResult { + manifestPath: string + marketplacePath: string + mcpPath: string + outdated: string[] +} + +export function buildCodexMarketplace( + options?: CodexPluginOptions | undefined, +): Record { + const { root = ROOT } = { + __proto__: null, + ...options, + } as CodexPluginOptions + const name = readPluginName(root) + return { + name, + interface: { displayName: DISPLAY_NAME }, + plugins: [ + { + name, + source: { source: 'local', path: CODEX_PLUGIN_SOURCE }, + policy: { installation: 'AVAILABLE', authentication: 'ON_INSTALL' }, + category: CATEGORY, + }, + ], + } +} + +export function buildCodexMcpConfig(): Record { + return { + mcpServers: { + [SOCKET_MCP_SERVER_NAME]: { + type: 'http', + url: SOCKET_MCP_URL, + scopes: [...SOCKET_MCP_SCOPES], + }, + }, + } +} + +export function buildCodexPluginManifest( + options?: CodexPluginOptions | undefined, +): Record { + const { root = ROOT } = { + __proto__: null, + ...options, + } as CodexPluginOptions + const src = loadJson(repoClaudePluginPath(root)) + const name = readPluginName(root) + + const skillRoots = collectSkillRoots(path.join(root, 'skills')) + if (skillRoots.length === 0) { + throw new Error('No skills discovered under skills/*/SKILL.md') + } + + const manifest: Record = { name } + const passthroughKeys = ['version', 'description', 'keywords'] + for (let i = 0, { length } = passthroughKeys; i < length; i += 1) { + const key = passthroughKeys[i]! + if (key in src) { + manifest[key] = src[key] + } + } + manifest['skills'] = skillRoots + manifest['mcpServers'] = CODEX_MCP_RELATIVE + + const author = src['author'] + const developerName = + author && typeof author === 'object' && 'name' in author + ? (author as { name: unknown }).name + : undefined + const iface: Record = { + displayName: DISPLAY_NAME, + shortDescription: + 'Scan, inspect, patch, and fix dependencies with Socket security data', + } + if (typeof src['description'] === 'string') { + iface['longDescription'] = src['description'] + } + if (typeof developerName === 'string') { + iface['developerName'] = developerName + } + iface['category'] = CATEGORY + iface['capabilities'] = ['Read', 'Write'] + iface['websiteURL'] = 'https://socket.dev' + iface['defaultPrompt'] = [...DEFAULT_PROMPTS] + manifest['interface'] = iface + + return manifest +} + +/** + * Top-level skill directories as Codex manifest paths ('./'). Codex scans + * each root recursively, so subskills (socket-fix/socket-dep-patch, ...) are + * found without listing them. The plugin root is skills/ itself, and Codex + * rejects a bare './' skills path, so the roots are listed explicitly. + */ +export function collectSkillRoots(skillsDir: string): string[] { + if (!existsSync(skillsDir)) { + return [] + } + const roots: string[] = [] + const entries = readdirSync(skillsDir, { withFileTypes: true }).toSorted( + (a, b) => a.name.localeCompare(b.name), + ) + for (let i = 0, { length } = entries; i < length; i += 1) { + const entry = entries[i]! + if ( + !entry.isDirectory() || + entry.name.startsWith('_') || + entry.name.startsWith('.') + ) { + continue + } + if (existsSync(path.join(skillsDir, entry.name, 'SKILL.md'))) { + roots.push(`./${entry.name}`) + } + } + return roots +} + +export function generateCodexPlugin( + root: string, + config: { check: boolean }, +): GenerateCodexResult { + const { check } = { __proto__: null, ...config } as typeof config + + const manifestPath = repoCodexPluginPath(root) + const mcpPath = repoCodexMcpPath(root) + const marketplacePath = repoCodexMarketplacePath(root) + + const artifacts: Array<[string, string]> = [ + [manifestPath, renderFormattedJson(buildCodexPluginManifest({ root }))], + [mcpPath, renderFormattedJson(buildCodexMcpConfig())], + [marketplacePath, renderFormattedJson(buildCodexMarketplace({ root }))], + ] + + const outdated: string[] = [] + for (let i = 0, { length } = artifacts; i < length; i += 1) { + const { 0: filePath, 1: content } = artifacts[i]! + if (!writeOrCheck(filePath, content, { check })) { + outdated.push(path.relative(root, filePath)) + } + } + + return { manifestPath, marketplacePath, mcpPath, outdated } +} + +/** + * Serialize JSON byte-identically to the repo formatter (oxfmt, printWidth 80): + * objects always break one key per line; an array of primitives stays on one + * line when that line, including any trailing comma, fits the width. + * publish.mts formats generated files after writing them, so any other shape + * would make this generator's --check report formatted files as stale. + */ +export function renderFormattedJson(data: unknown): string { + return `${renderValue(data, '', '', '')}\n` +} + +const PRINT_WIDTH = 80 + +function renderValue( + value: unknown, + indent: string, + prefix: string, + suffix: string, +): string { + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]' + } + const primitive = value.every(v => v === null || typeof v !== 'object') + if (primitive) { + const inline = `[${value.map(v => JSON.stringify(v)).join(', ')}]` + if ((indent + prefix + inline + suffix).length <= PRINT_WIDTH) { + return inline + } + } + const inner = `${indent} ` + const items = value.map( + (v, i) => + inner + renderValue(v, inner, '', i < value.length - 1 ? ',' : ''), + ) + return `[\n${items.join(',\n')}\n${indent}]` + } + if (value !== null && typeof value === 'object') { + const entries = Object.entries(value) + if (entries.length === 0) { + return '{}' + } + const inner = `${indent} ` + const lines = entries.map(([key, v], i) => { + const keyPrefix = `${JSON.stringify(key)}: ` + const comma = i < entries.length - 1 ? ',' : '' + return inner + keyPrefix + renderValue(v, inner, keyPrefix, comma) + }) + return `{\n${lines.join(',\n')}\n${indent}}` + } + return JSON.stringify(value) +} + +function readPluginName(root: string): string { + const name = loadJson(repoClaudePluginPath(root))['name'] + if (typeof name !== 'string' || !name) { + throw new Error(".claude-plugin/plugin.json must define a non-empty 'name'") + } + validatePluginName(name) + return name +} + +function main(): void { + const checkMode = process.argv.includes('--check') + + const result = generateCodexPlugin(ROOT, { check: checkMode }) + + if (checkMode) { + if (result.outdated.length > 0) { + logger.fail('Generated Codex artifacts are out of date:') + for (let i = 0, { length } = result.outdated; i < length; i += 1) { + logger.fail(` - ${result.outdated[i]}`) + } + logger.fail('Run: node scripts/repo/generate-codex-plugin.mts') + process.exit(1) + } + + logger.log('Codex plugin artifacts are up to date.') + return + } + + logger.log(`Wrote ${path.relative(ROOT, result.manifestPath)}`) + logger.log(`Wrote ${path.relative(ROOT, result.mcpPath)}`) + logger.log(`Wrote ${path.relative(ROOT, result.marketplacePath)}`) +} + +const SCRIPT_META: ScriptMeta = { + describe: 'generates OpenAI Codex plugin artifacts', + help: 'Usage: node scripts/repo/generate-codex-plugin.mts [--check]', + json: 'result', +} + +if (isMainModule(import.meta.url)) { + runMain(() => main(), SCRIPT_META) +} diff --git a/scripts/repo/paths.mts b/scripts/repo/paths.mts index a026d440..e08ff6b9 100644 --- a/scripts/repo/paths.mts +++ b/scripts/repo/paths.mts @@ -31,3 +31,15 @@ export function repoClaudePluginPath(root: string): string { export function repoGithubWorkflowsPath(root: string): string { return path.join(root, '.github', 'workflows') } + +export function repoCodexMarketplacePath(root: string): string { + return path.join(root, '.agents', 'plugins', 'marketplace.json') +} + +export function repoCodexMcpPath(root: string): string { + return path.join(root, 'skills', '.codex-plugin', 'mcp.json') +} + +export function repoCodexPluginPath(root: string): string { + return path.join(root, 'skills', '.codex-plugin', 'plugin.json') +} diff --git a/scripts/repo/publish.mts b/scripts/repo/publish.mts index 1b8c80fb..25d389a9 100644 --- a/scripts/repo/publish.mts +++ b/scripts/repo/publish.mts @@ -32,6 +32,9 @@ const GENERATED_FILES = [ '.cursor-plugin/mcp.json', '.claude-plugin/plugin.json', '.claude-plugin/marketplace.json', + '.agents/plugins/marketplace.json', + 'skills/.codex-plugin/plugin.json', + 'skills/.codex-plugin/mcp.json', 'gemini-extension.json', ] as const @@ -40,6 +43,7 @@ const GENERATORS = [ 'scripts/repo/sync-versions.mts', 'scripts/repo/generate-agents.mts', 'scripts/repo/generate-cursor-plugin.mts', + 'scripts/repo/generate-codex-plugin.mts', ] as const const MISSING = '__MISSING__' @@ -101,6 +105,7 @@ async function check(): Promise { } await runScript('scripts/repo/generate-cursor-plugin.mts', '--check') + await runScript('scripts/repo/generate-codex-plugin.mts', '--check') await runScript('scripts/repo/inline-shared.mts', '--check') process.stdout.write('All generated artifacts are up to date.\n') } diff --git a/skills/.codex-plugin/mcp.json b/skills/.codex-plugin/mcp.json new file mode 100644 index 00000000..05023d45 --- /dev/null +++ b/skills/.codex-plugin/mcp.json @@ -0,0 +1,9 @@ +{ + "mcpServers": { + "socket": { + "type": "http", + "url": "https://mcp.socket.dev/", + "scopes": ["packages:list", "alerts:list", "threat-feed:list"] + } + } +} diff --git a/skills/.codex-plugin/plugin.json b/skills/.codex-plugin/plugin.json new file mode 100644 index 00000000..8b0d1f55 --- /dev/null +++ b/skills/.codex-plugin/plugin.json @@ -0,0 +1,38 @@ +{ + "name": "socket-skills", + "version": "1.0.0", + "description": "Agent Skills for dependency security including vulnerability scanning, package review, patching, setup and configuration, and secure dependency updates via Socket.dev", + "keywords": [ + "socket", + "security", + "dependencies", + "vulnerabilities", + "supply-chain", + "scanning", + "firewall", + "patching" + ], + "skills": [ + "./socket-connect", + "./socket-fix", + "./socket-inspect", + "./socket-release", + "./socket-scan", + "./socket-setup" + ], + "mcpServers": "./.codex-plugin/mcp.json", + "interface": { + "displayName": "Socket", + "shortDescription": "Scan, inspect, patch, and fix dependencies with Socket security data", + "longDescription": "Agent Skills for dependency security including vulnerability scanning, package review, patching, setup and configuration, and secure dependency updates via Socket.dev", + "developerName": "Socket", + "category": "Developer Tools", + "capabilities": ["Read", "Write"], + "websiteURL": "https://socket.dev", + "defaultPrompt": [ + "Scan this project's dependencies with Socket.", + "Check an npm package with Socket before I add it.", + "Fix the vulnerable dependencies in this project." + ] + } +} diff --git a/skills/socket-connect/SKILL.md b/skills/socket-connect/SKILL.md index 88bb0814..57d075fe 100644 --- a/skills/socket-connect/SKILL.md +++ b/skills/socket-connect/SKILL.md @@ -22,7 +22,7 @@ This plugin ships the hosted Socket MCP server (`https://mcp.socket.dev/`). Othe ## Step 1: Check Whether Socket Is Connected -Look for a Socket MCP tool named `organizations` among your available tools. The full tool name depends on how the server was added. For example, it can be `mcp__plugin_socket-connect_socket__organizations` or `mcp__socket-mcp__organizations`. Match on the server URL or the `organizations` suffix, never on one hard-coded prefix. +Look for a Socket MCP tool named `organizations` among your available tools. The full tool name depends on how the server was added. For example, it can be `mcp__plugin_socket-connect_socket__organizations`, `mcp__socket__organizations`, or `mcp__socket-mcp__organizations`. Match on the server URL or the `organizations` suffix, never on one hard-coded prefix. - If the tool exists, call it. A list of organizations means the user is connected. Go to Step 3. - If the call fails with an authentication error, or the tool is missing, go to Step 2. @@ -31,13 +31,13 @@ Look for a Socket MCP tool named `organizations` among your available tools. The Tell the user how to sign in for their agent. Nobody needs an API key. -| Agent | How to sign in | -| ------------------- | ---------------------------------------------------------------------------------------------- | -| Claude Code | Run `/mcp`, pick the Socket server, then choose **Authenticate**. A browser window opens. | -| Claude Code (shell) | `claude mcp login `, using the name shown by `/mcp`. Add `--no-browser` over SSH. | -| Cursor | Open **Customize** in the sidebar, select the Socket server, and sign in to it. | -| Gemini CLI | Run `/mcp auth socket`. | -| Codex | Add `https://mcp.socket.dev/` as an MCP server, then run `codex mcp login `. | +| Agent | How to sign in | +| ------------------- | ----------------------------------------------------------------------------------------------- | +| Claude Code | Run `/mcp`, pick the Socket server, then choose **Authenticate**. A browser window opens. | +| Claude Code (shell) | `claude mcp login `, using the name shown by `/mcp`. Add `--no-browser` over SSH. | +| Cursor | Open **Customize** in the sidebar, select the Socket server, and sign in to it. | +| Gemini CLI | Run `/mcp auth socket`. | +| Codex | Run `/mcp login socket`, or `codex mcp login socket` from a shell. Add `--no-browser` over SSH. | If the Socket server is not listed at all, add it by URL. In Claude Code: diff --git a/test/repo/unit/generate-codex-plugin.test.mts b/test/repo/unit/generate-codex-plugin.test.mts new file mode 100644 index 00000000..0440f67f --- /dev/null +++ b/test/repo/unit/generate-codex-plugin.test.mts @@ -0,0 +1,240 @@ +// Specs for scripts/repo/generate-codex-plugin.mts. +// +// The generator must never touch the fleet's root .mcp.json inventory or the +// generated .codex/ adapter dir, and must keep every Codex manifest path in +// the './'-prefixed form Codex requires. + +import { expect, it } from 'vitest' + +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + writeFileSync, +} from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { safeDeleteSync } from '@socketsecurity/lib-stable/fs/safe' + +import { + buildCodexMarketplace, + buildCodexMcpConfig, + buildCodexPluginManifest, + collectSkillRoots, + generateCodexPlugin, + renderFormattedJson, + SOCKET_MCP_URL, +} from '../../../scripts/repo/generate-codex-plugin.mts' +import { renderJson } from '../../../scripts/repo/generate-cursor-plugin.mts' + +const POPULATED_MCP = renderJson({ + mcpServers: { + linear: { type: 'http', url: 'https://mcp.linear.app/mcp' }, + }, +}) + +function writeSkill(root: string, rel: string, name: string): void { + mkdirSync(path.join(root, 'skills', rel), { recursive: true }) + writeFileSync( + path.join(root, 'skills', rel, 'SKILL.md'), + `---\nname: ${name}\ndescription: A demo skill.\n---\n\n# ${name}\n`, + 'utf-8', + ) +} + +function makeFixtureRoot(): string { + const root = mkdtempSync(path.join(os.tmpdir(), 'codex-plugin-fixture-')) + mkdirSync(path.join(root, '.claude-plugin'), { recursive: true }) + writeFileSync( + path.join(root, '.claude-plugin', 'plugin.json'), + renderJson({ + name: 'fixture-skills', + version: '9.9.9', + description: 'Fixture skills.', + author: { name: 'Fixture Co' }, + homepage: 'https://example.com', + }), + 'utf-8', + ) + writeSkill(root, 'beta', 'beta') + writeSkill(root, 'alpha', 'alpha') + writeSkill(root, path.join('alpha', 'alpha-sub'), 'alpha-sub') + mkdirSync(path.join(root, 'skills', '_shared', 'docs'), { recursive: true }) + mkdirSync(path.join(root, 'skills', 'no-skill-md'), { recursive: true }) + // Dot and underscore dirs are skipped even when they hold a SKILL.md. + writeSkill(root, '.hidden', 'hidden') + writeSkill(root, '_private', 'private') + writeFileSync(path.join(root, '.mcp.json'), POPULATED_MCP, 'utf-8') + return root +} + +function readJson(filePath: string): unknown { + return JSON.parse(readFileSync(filePath, 'utf-8')) +} + +it('generate leaves root .mcp.json byte-identical and writes no .codex/', () => { + const root = makeFixtureRoot() + try { + generateCodexPlugin(root, { check: false }) + + expect(readFileSync(path.join(root, '.mcp.json'), 'utf-8')).toBe( + POPULATED_MCP, + ) + expect(existsSync(path.join(root, '.codex'))).toBe(false) + expect(existsSync(path.join(root, 'skills', '.mcp.json'))).toBe(false) + } finally { + safeDeleteSync(root) + } +}) + +it('generate writes the three Codex artifacts at their discovery paths', () => { + const root = makeFixtureRoot() + try { + const result = generateCodexPlugin(root, { check: false }) + + expect(result.manifestPath).toBe( + path.join(root, 'skills', '.codex-plugin', 'plugin.json'), + ) + expect(result.mcpPath).toBe( + path.join(root, 'skills', '.codex-plugin', 'mcp.json'), + ) + expect(result.marketplacePath).toBe( + path.join(root, '.agents', 'plugins', 'marketplace.json'), + ) + expect(readJson(result.mcpPath)).toStrictEqual(buildCodexMcpConfig()) + expect(readJson(result.manifestPath)).toStrictEqual( + buildCodexPluginManifest({ root }), + ) + expect(readJson(result.marketplacePath)).toStrictEqual( + buildCodexMarketplace({ root }), + ) + } finally { + safeDeleteSync(root) + } +}) + +it('mcp config points Codex at the hosted Socket server over OAuth', () => { + const config = buildCodexMcpConfig() as { + mcpServers: Record> + } + const server = config.mcpServers['socket']! + expect(server['url']).toBe(SOCKET_MCP_URL) + expect(server['scopes']).toStrictEqual([ + 'packages:list', + 'alerts:list', + 'threat-feed:list', + ]) + // A bearer token setting disables Codex's OAuth flow. + expect(server).not.toHaveProperty('bearer_token_env_var') + expect(server).not.toHaveProperty('http_headers') +}) + +it('manifest uses ./-prefixed paths and copies identity from the Claude manifest', () => { + const root = makeFixtureRoot() + try { + const manifest = buildCodexPluginManifest({ root }) + expect(manifest['name']).toBe('fixture-skills') + expect(manifest['version']).toBe('9.9.9') + expect(manifest['description']).toBe('Fixture skills.') + expect(manifest['mcpServers']).toBe('./.codex-plugin/mcp.json') + expect(manifest['skills']).toStrictEqual(['./alpha', './beta']) + const iface = manifest['interface'] as Record + expect(iface['developerName']).toBe('Fixture Co') + const prompts = iface['defaultPrompt'] as string[] + expect(prompts.length).toBeLessThanOrEqual(3) + for (let i = 0, { length } = prompts; i < length; i += 1) { + expect(prompts[i]!.length).toBeLessThanOrEqual(128) + } + } finally { + safeDeleteSync(root) + } +}) + +it('skill roots skip _shared, dot dirs, dirs without SKILL.md, and subskills', () => { + const root = makeFixtureRoot() + try { + generateCodexPlugin(root, { check: false }) + expect(collectSkillRoots(path.join(root, 'skills'))).toStrictEqual([ + './alpha', + './beta', + ]) + } finally { + safeDeleteSync(root) + } +}) + +it('marketplace entry sources the skills dir under the manifest name', () => { + const root = makeFixtureRoot() + try { + const marketplace = buildCodexMarketplace({ root }) as { + name: string + plugins: Array> + } + expect(marketplace.name).toBe('fixture-skills') + expect(marketplace.plugins).toHaveLength(1) + const entry = marketplace.plugins[0]! + expect(entry['name']).toBe(buildCodexPluginManifest({ root })['name']) + expect(entry['source']).toStrictEqual({ source: 'local', path: './skills' }) + expect(entry['policy']).toStrictEqual({ + installation: 'AVAILABLE', + authentication: 'ON_INSTALL', + }) + } finally { + safeDeleteSync(root) + } +}) + +it('check mode reports stale artifacts without writing anything', () => { + const root = makeFixtureRoot() + try { + const result = generateCodexPlugin(root, { check: true }) + + expect(result.outdated.toSorted()).toStrictEqual([ + path.join('.agents', 'plugins', 'marketplace.json'), + path.join('skills', '.codex-plugin', 'mcp.json'), + path.join('skills', '.codex-plugin', 'plugin.json'), + ]) + expect(existsSync(path.join(root, '.agents'))).toBe(false) + expect(existsSync(path.join(root, 'skills', '.codex-plugin'))).toBe(false) + } finally { + safeDeleteSync(root) + } +}) + +it('generate is idempotent: second check run reports up to date', () => { + const root = makeFixtureRoot() + try { + generateCodexPlugin(root, { check: false }) + const result = generateCodexPlugin(root, { check: true }) + expect(result.outdated).toStrictEqual([]) + } finally { + safeDeleteSync(root) + } +}) + +it('renderFormattedJson keeps short primitive arrays inline and breaks long ones', () => { + const longItems = Array.from({ length: 8 }, (_, i) => `item-number-${i}`) + expect( + renderFormattedJson({ short: ['a', 'b'], long: longItems, nested: {} }), + ).toBe( + '{\n' + + ' "short": ["a", "b"],\n' + + ' "long": [\n' + + longItems.map(item => ` "${item}"`).join(',\n') + + '\n ],\n' + + ' "nested": {}\n' + + '}\n', + ) +}) + +it('renderFormattedJson counts the trailing comma against the print width', () => { + // 80 columns with no comma fits; the same array followed by a sibling key + // needs a trailing comma, so it reaches 81 and must break. + const item = 'x'.repeat(69) + expect(renderFormattedJson({ k: [item] })).toBe(`{\n "k": ["${item}"]\n}\n`) + expect(renderFormattedJson({ k: [item], z: 1 })).toBe( + `{\n "k": [\n "${item}"\n ],\n "z": 1\n}\n`, + ) +}) diff --git a/test/repo/unit/structural/paths-manifest-consistency.test.mts b/test/repo/unit/structural/paths-manifest-consistency.test.mts index 7e93c9c4..5a457a4e 100644 --- a/test/repo/unit/structural/paths-manifest-consistency.test.mts +++ b/test/repo/unit/structural/paths-manifest-consistency.test.mts @@ -21,7 +21,10 @@ interface Marketplace { export function getSkillDirs(): string[] { return readdirSync(SKILLS_DIR, { withFileTypes: true }) - .filter(e => e.isDirectory() && !e.name.startsWith('_')) + .filter( + e => + e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'), + ) .map(e => e.name) .toSorted() } @@ -66,10 +69,14 @@ describe('Manifest Consistency', () => { const cursor = loadJSON('.cursor-plugin/mcp.json') as { mcpServers: Record } + const codex = loadJSON('skills/.codex-plugin/mcp.json') as { + mcpServers: Record + } /* eslint-enable typescript/no-unsafe-type-assertion */ expect(connect?.mcpServers?.['socket']?.url).toBe(SOCKET_MCP_URL) expect(gemini.mcpServers?.['socket']?.httpUrl).toBe(SOCKET_MCP_URL) expect(cursor.mcpServers['socket']?.url).toBe(SOCKET_MCP_URL) + expect(codex.mcpServers['socket']?.url).toBe(SOCKET_MCP_URL) }) }) @@ -134,6 +141,9 @@ describe('Manifest Consistency', () => { const cursorJson = loadJSON('.cursor-plugin/plugin.json') as { version: string } + const codexJson = loadJSON('skills/.codex-plugin/plugin.json') as { + version: string + } /* eslint-enable typescript/no-unsafe-type-assertion */ const versions = { @@ -141,6 +151,7 @@ describe('Manifest Consistency', () => { 'gemini-extension.json': geminiJson.version, 'plugin.json': pluginJson.version, 'cursor plugin.json': cursorJson.version, + 'codex plugin.json': codexJson.version, } const uniqueVersions = [...new Set(Object.values(versions))] @@ -150,4 +161,36 @@ describe('Manifest Consistency', () => { ).toHaveLength(1) }) }) + + describe('codex marketplace', () => { + /* eslint-disable typescript/no-unsafe-type-assertion -- same rationale + as the suite-level manifests above. */ + const codexMarketplace = loadJSON('.agents/plugins/marketplace.json') as { + plugins: Array<{ name: string; source: { path: string } }> + } + /* eslint-enable typescript/no-unsafe-type-assertion */ + + it('every plugin source holds a Codex manifest with the same name', () => { + const plugins = codexMarketplace.plugins + expect(plugins.length).toBeGreaterThan(0) + for (let i = 0, { length } = plugins; i < length; i += 1) { + const plugin = plugins[i]! + const manifestPath = path.join( + REPO_ROOT, + plugin.source.path, + '.codex-plugin', + 'plugin.json', + ) + expect( + existsSync(manifestPath), + `codex plugin '${plugin.name}' source '${plugin.source.path}' has no .codex-plugin/plugin.json`, + ).toBe(true) + /* eslint-disable-next-line typescript/no-unsafe-type-assertion -- the manifest is under test. */ + const manifest = JSON.parse(readFileSync(manifestPath, 'utf-8')) as { + name: string + } + expect(manifest.name).toBe(plugin.name) + } + }) + }) }) diff --git a/test/repo/unit/structural/paths-skill-content-quality.test.mts b/test/repo/unit/structural/paths-skill-content-quality.test.mts index ccd12534..2ec44309 100644 --- a/test/repo/unit/structural/paths-skill-content-quality.test.mts +++ b/test/repo/unit/structural/paths-skill-content-quality.test.mts @@ -11,7 +11,10 @@ export function getSkillContent(dir: string): string { export function getSkillDirs(): string[] { return readdirSync(SKILLS_DIR, { withFileTypes: true }) - .filter(e => e.isDirectory() && !e.name.startsWith('_')) + .filter( + e => + e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'), + ) .map(e => e.name) .toSorted() } diff --git a/test/repo/unit/structural/paths-skill-discovery.test.mts b/test/repo/unit/structural/paths-skill-discovery.test.mts index 7de7a52e..7dfabd2a 100644 --- a/test/repo/unit/structural/paths-skill-discovery.test.mts +++ b/test/repo/unit/structural/paths-skill-discovery.test.mts @@ -49,7 +49,10 @@ function getAllSkillPaths(): string[] { export function getSkillDirs(): string[] { return readdirSync(SKILLS_DIR, { withFileTypes: true }) - .filter(e => e.isDirectory() && !e.name.startsWith('_')) + .filter( + e => + e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'), + ) .map(e => e.name) .toSorted() } @@ -60,7 +63,10 @@ export function getSubSkillDirs(parent: string): string[] { return [] } return readdirSync(parentDir, { withFileTypes: true }) - .filter(e => e.isDirectory() && !e.name.startsWith('_')) + .filter( + e => + e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'), + ) .map(e => e.name) .toSorted() }