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() }