From e97f85bd53a493403a3405217d3e5e4de51ed8ba Mon Sep 17 00:00:00 2001 From: Kieran Klaassen Date: Wed, 21 Jan 2026 17:00:30 -0800 Subject: [PATCH] feat: add OpenCode/Codex outputs and update changelog (#104) * Add OpenCode converter coverage and specs * Add Codex target support and spec docs * Generate Codex command skills and refresh spec docs * Add global Codex install path * fix: harden plugin path loading and codex descriptions * feat: ensure codex agents block on convert/install * docs: clarify target branch usage for review * chore: prep npm package metadata and release notes * docs: mention opencode and codex in changelog * docs: update CLI usage and remove stale todos * feat: install from GitHub with global outputs --- .gitignore | 1 + AGENTS.md | 48 +++ README.md | 22 + bun.lock | 30 ++ docs/specs/claude-code.md | 67 +++ docs/specs/codex.md | 59 +++ docs/specs/opencode.md | 57 +++ package.json | 26 ++ plugins/compound-engineering/CHANGELOG.md | 28 ++ .../commands/workflows/review.md | 4 +- .../skills/git-worktree/SKILL.md | 6 +- src/commands/convert.ts | 156 +++++++ src/commands/install.ts | 221 ++++++++++ src/commands/list.ts | 37 ++ src/converters/claude-to-codex.ts | 124 ++++++ src/converters/claude-to-opencode.ts | 392 ++++++++++++++++++ src/index.ts | 20 + src/parsers/claude.ts | 248 +++++++++++ src/targets/codex.ts | 91 ++++ src/targets/index.ts | 29 ++ src/targets/opencode.ts | 48 +++ src/types/claude.ts | 88 ++++ src/types/codex.ts | 23 + src/types/opencode.ts | 54 +++ src/utils/codex-agents.ts | 64 +++ src/utils/files.ts | 64 +++ src/utils/frontmatter.ts | 65 +++ tests/claude-parser.test.ts | 89 ++++ tests/cli.test.ts | 289 +++++++++++++ tests/codex-agents.test.ts | 62 +++ tests/codex-converter.test.ts | 121 ++++++ tests/codex-writer.test.ts | 76 ++++ tests/converter.test.ts | 171 ++++++++ .../custom-paths/.claude-plugin/plugin.json | 8 + .../custom-paths/agents/default-agent.md | 5 + .../custom-paths/commands/default-command.md | 5 + .../custom-agents/custom-agent.md | 5 + .../custom-commands/custom-command.md | 5 + .../custom-paths/custom-hooks/hooks.json | 7 + .../custom-skills/custom-skill/SKILL.md | 5 + tests/fixtures/custom-paths/hooks/hooks.json | 7 + .../skills/default-skill/SKILL.md | 5 + .../.claude-plugin/plugin.json | 5 + .../.claude-plugin/plugin.json | 5 + .../.claude-plugin/plugin.json | 5 + .../mcp-file/.claude-plugin/plugin.json | 5 + tests/fixtures/mcp-file/.mcp.json | 6 + .../sample-plugin/.claude-plugin/plugin.json | 30 ++ .../sample-plugin/agents/agent-one.md | 10 + .../sample-plugin/agents/security-reviewer.md | 7 + .../sample-plugin/commands/command-one.md | 7 + .../sample-plugin/commands/model-command.md | 8 + .../commands/nested/command-two.md | 9 + .../sample-plugin/commands/pattern-command.md | 7 + .../sample-plugin/commands/skill-command.md | 7 + .../sample-plugin/commands/todo-command.md | 7 + tests/fixtures/sample-plugin/hooks/hooks.json | 156 +++++++ .../sample-plugin/skills/skill-one/SKILL.md | 6 + tests/frontmatter.test.ts | 20 + tests/opencode-writer.test.ts | 62 +++ tsconfig.json | 14 + 61 files changed, 3303 insertions(+), 5 deletions(-) create mode 100644 AGENTS.md create mode 100644 bun.lock create mode 100644 docs/specs/claude-code.md create mode 100644 docs/specs/codex.md create mode 100644 docs/specs/opencode.md create mode 100644 package.json create mode 100644 src/commands/convert.ts create mode 100644 src/commands/install.ts create mode 100644 src/commands/list.ts create mode 100644 src/converters/claude-to-codex.ts create mode 100644 src/converters/claude-to-opencode.ts create mode 100644 src/index.ts create mode 100644 src/parsers/claude.ts create mode 100644 src/targets/codex.ts create mode 100644 src/targets/index.ts create mode 100644 src/targets/opencode.ts create mode 100644 src/types/claude.ts create mode 100644 src/types/codex.ts create mode 100644 src/types/opencode.ts create mode 100644 src/utils/codex-agents.ts create mode 100644 src/utils/files.ts create mode 100644 src/utils/frontmatter.ts create mode 100644 tests/claude-parser.test.ts create mode 100644 tests/cli.test.ts create mode 100644 tests/codex-agents.test.ts create mode 100644 tests/codex-converter.test.ts create mode 100644 tests/codex-writer.test.ts create mode 100644 tests/converter.test.ts create mode 100644 tests/fixtures/custom-paths/.claude-plugin/plugin.json create mode 100644 tests/fixtures/custom-paths/agents/default-agent.md create mode 100644 tests/fixtures/custom-paths/commands/default-command.md create mode 100644 tests/fixtures/custom-paths/custom-agents/custom-agent.md create mode 100644 tests/fixtures/custom-paths/custom-commands/custom-command.md create mode 100644 tests/fixtures/custom-paths/custom-hooks/hooks.json create mode 100644 tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md create mode 100644 tests/fixtures/custom-paths/hooks/hooks.json create mode 100644 tests/fixtures/custom-paths/skills/default-skill/SKILL.md create mode 100644 tests/fixtures/invalid-command-path/.claude-plugin/plugin.json create mode 100644 tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json create mode 100644 tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json create mode 100644 tests/fixtures/mcp-file/.claude-plugin/plugin.json create mode 100644 tests/fixtures/mcp-file/.mcp.json create mode 100644 tests/fixtures/sample-plugin/.claude-plugin/plugin.json create mode 100644 tests/fixtures/sample-plugin/agents/agent-one.md create mode 100644 tests/fixtures/sample-plugin/agents/security-reviewer.md create mode 100644 tests/fixtures/sample-plugin/commands/command-one.md create mode 100644 tests/fixtures/sample-plugin/commands/model-command.md create mode 100644 tests/fixtures/sample-plugin/commands/nested/command-two.md create mode 100644 tests/fixtures/sample-plugin/commands/pattern-command.md create mode 100644 tests/fixtures/sample-plugin/commands/skill-command.md create mode 100644 tests/fixtures/sample-plugin/commands/todo-command.md create mode 100644 tests/fixtures/sample-plugin/hooks/hooks.json create mode 100644 tests/fixtures/sample-plugin/skills/skill-one/SKILL.md create mode 100644 tests/frontmatter.test.ts create mode 100644 tests/opencode-writer.test.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index 7cfd02b..c9f2f33 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store *.log node_modules/ +.codex/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..471b900 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,48 @@ +# Agent Instructions + +This repository contains a Bun/TypeScript CLI that converts Claude Code plugins into other agent platform formats. + +## Working Agreement + +- **Branching:** Create a feature branch for any non-trivial change. If already on the correct branch for the task, keep using it; do not create additional branches or worktrees unless explicitly requested. +- **Safety:** Do not delete or overwrite user data. Avoid destructive commands. +- **Testing:** Run `bun test` after changes that affect parsing, conversion, or output. +- **Output Paths:** Keep OpenCode output at `opencode.json` and `.opencode/{agents,skills,plugins}`. +- **ASCII-first:** Use ASCII unless the file already contains Unicode. + +## Adding a New Target Provider (e.g., Codex) + +Use this checklist when introducing a new target provider: + +1. **Define the target entry** + - Add a new handler in `src/targets/index.ts` with `implemented: false` until complete. + - Use a dedicated writer module (e.g., `src/targets/codex.ts`). + +2. **Define types and mapping** + - Add provider-specific types under `src/types/`. + - Implement conversion logic in `src/converters/` (from Claude → provider). + - Keep mappings explicit: tools, permissions, hooks/events, model naming. + +3. **Wire the CLI** + - Ensure `convert` and `install` support `--to ` and `--also`. + - Keep behavior consistent with OpenCode (write to a clean provider root). + +4. **Tests (required)** + - Extend fixtures in `tests/fixtures/sample-plugin`. + - Add spec coverage for mappings in `tests/converter.test.ts`. + - Add a writer test for the new provider output tree. + - Add a CLI test for the provider (similar to `tests/cli.test.ts`). + +5. **Docs** + - Update README with the new `--to` option and output locations. + +## When to Add a Provider + +Add a new provider when at least one of these is true: + +- A real user/workflow needs it now. +- The target format is stable and documented. +- There’s a clear mapping for tools/permissions/hooks. +- You can write fixtures + tests that validate the mapping. + +Avoid adding a provider if the target spec is unstable or undocumented. diff --git a/README.md b/README.md index ec49541..8c9f906 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,28 @@ A Claude Code plugin marketplace featuring the **Compound Engineering Plugin** /plugin install compound-engineering ``` +## OpenCode + Codex support (experimental) + +This repo includes a Bun/TypeScript CLI that converts Claude Code plugins to OpenCode and Codex. + +```bash +# convert the compound-engineering plugin into OpenCode format +bunx @every-env/compound-plugin install compound-engineering --to opencode + +# convert to Codex format +bunx @every-env/compound-plugin install compound-engineering --to codex +``` + +Local dev: + +```bash +bun run src/index.ts install ./plugins/compound-engineering --to opencode +``` + +OpenCode output is written to `~/.opencode` by default, with `opencode.json` at the root and `agents/`, `skills/`, and `plugins/` alongside it. +Both provider targets are experimental and may change as the formats evolve. +Codex output is written to `~/.codex/prompts` and `~/.codex/skills`, with each Claude command converted into both a prompt and a skill (the prompt instructs Codex to load the corresponding skill). Generated Codex skill descriptions are truncated to 1024 characters (Codex limit). + ## Workflow ``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..26361fc --- /dev/null +++ b/bun.lock @@ -0,0 +1,30 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "compound-plugin", + "dependencies": { + "citty": "^0.1.6", + "js-yaml": "^4.1.0", + }, + "devDependencies": { + "bun-types": "^1.0.0", + }, + }, + }, + "packages": { + "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], + + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + } +} diff --git a/docs/specs/claude-code.md b/docs/specs/claude-code.md new file mode 100644 index 0000000..445e561 --- /dev/null +++ b/docs/specs/claude-code.md @@ -0,0 +1,67 @@ +# Claude Code Plugin Spec + +Last verified: 2026-01-21 + +## Primary sources + +``` +https://docs.claude.com/en/docs/claude-code/plugins-reference +https://docs.claude.com/en/docs/claude-code/hooks +https://docs.claude.com/en/docs/claude-code/slash-commands +https://docs.claude.com/en/docs/claude-code/skills +https://docs.claude.com/en/docs/claude-code/plugin-marketplaces +``` + +## Plugin layout and file locations + +- A plugin root contains `.claude-plugin/plugin.json` and optional default directories like `commands/`, `agents/`, `skills/`, `hooks/`, plus `.mcp.json` and `.lsp.json` at the plugin root. citeturn2view7 +- The `.claude-plugin/` directory only holds the manifest; component directories (commands/agents/skills/hooks) must be at the plugin root, not inside `.claude-plugin/`. citeturn2view7 +- The reference table lists default locations and notes that `commands/` is the legacy home for skills; new skills should live under `skills//SKILL.md`. citeturn2view7 + +## Manifest schema (`.claude-plugin/plugin.json`) + +- `name` is required and must be kebab-case with no spaces. citeturn2view8 +- Metadata fields include `version`, `description`, `author`, `homepage`, `repository`, `license`, and `keywords`. citeturn2view8 +- Component path fields include `commands`, `agents`, `skills`, `hooks`, `mcpServers`, `outputStyles`, and `lspServers`. These can be strings or arrays, or inline objects for hooks/MCP/LSP. citeturn2view8turn2view9 +- Custom paths supplement defaults; they do not replace them, and all paths must be relative to the plugin root and start with `./`. citeturn2view9 + +## Commands (slash commands) + +- Command files are Markdown with frontmatter. Supported frontmatter includes `allowed-tools`, `argument-hint`, `description`, `model`, and `disable-model-invocation`, each with documented defaults. citeturn6search0 + +## Skills (`skills//SKILL.md`) + +- Skills are directories containing `SKILL.md` (plus optional support files). Skills and commands are auto-discovered when the plugin is installed. citeturn2view7 +- Skills can be invoked with `/` and are stored in `~/.claude/skills` or `.claude/skills` (project-level); plugins can also ship skills. citeturn12view0 +- Skill frontmatter examples include `name`, `description`, and optional `allowed-tools`. citeturn12view0 + +## Agents (`agents/*.md`) + +- Agents are markdown files with frontmatter such as `description` and `capabilities`, plus descriptive content for when to invoke the agent. citeturn2view7 + +## Hooks (`hooks/hooks.json` or inline) + +- Hooks can be provided in `hooks/hooks.json` or inline via the manifest. Hooks are organized by event → matcher → hook list. citeturn2view7 +- Plugin hooks are merged with user and project hooks when the plugin is enabled, and matching hooks run in parallel. citeturn1search0 +- Supported events include `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `UserPromptSubmit`, `Notification`, `Stop`, `SubagentStart`, `SubagentStop`, `Setup`, `SessionStart`, `SessionEnd`, and `PreCompact`. citeturn2view7 +- Hook types include `command`, `prompt`, and `agent`. citeturn2view7 +- Hooks can use `${CLAUDE_PLUGIN_ROOT}` to reference plugin files. citeturn1search0 + +## MCP servers + +- Plugins can define MCP servers in `.mcp.json` or inline under `mcpServers` in the manifest. Configuration includes `command`, `args`, `env`, and `cwd`. citeturn2view7turn2view10 +- Plugin MCP servers start automatically when enabled and appear as standard MCP tools. citeturn2view10 + +## LSP servers + +- LSP servers can be defined in `.lsp.json` or inline in the manifest. Required fields include `command` and `extensionToLanguage`, with optional settings for transport, args, env, and timeouts. citeturn2view7turn2view10 + +## Plugin caching and path limits + +- Claude Code copies plugin files into a cache directory instead of using them in place. Plugins cannot access paths outside the copied root (for example, `../shared-utils`). citeturn2view12 +- To access external files, use symlinks inside the plugin directory or restructure your marketplace so the plugin root contains shared files. citeturn2view12 + +## Marketplace schema (`.claude-plugin/marketplace.json`) + +- A marketplace JSON file lists plugins and includes fields for marketplace metadata and a `plugins` array. citeturn8view2 +- Each plugin entry includes at least a `name` and `source` and can include additional manifest fields. citeturn8view2 diff --git a/docs/specs/codex.md b/docs/specs/codex.md new file mode 100644 index 0000000..8d27246 --- /dev/null +++ b/docs/specs/codex.md @@ -0,0 +1,59 @@ +# Codex Spec (Config, Prompts, Skills, MCP) + +Last verified: 2026-01-21 + +## Primary sources + +``` +https://developers.openai.com/codex/config-basic +https://developers.openai.com/codex/config-advanced +https://developers.openai.com/codex/custom-prompts +https://developers.openai.com/codex/skills +https://developers.openai.com/codex/skills/create-skill +https://developers.openai.com/codex/guides/agents-md +https://developers.openai.com/codex/mcp +``` + +## Config location and precedence + +- Codex reads local settings from `~/.codex/config.toml`, shared by the CLI and IDE extension. citeturn2view0 +- Configuration precedence is: CLI flags → profile values → root-level values in `config.toml` → built-in defaults. citeturn2view0 +- Codex stores local state under `CODEX_HOME` (defaults to `~/.codex`) and includes `config.toml` there. citeturn4view0 + +## Profiles and providers + +- Profiles are defined under `[profiles.]` and selected with `codex --profile `. citeturn4view0 +- A top-level `profile = ""` sets the default profile; CLI flags can override it. citeturn4view0 +- Profiles are experimental and not supported in the IDE extension. citeturn4view0 +- Custom model providers can be defined with base URL, wire API, and optional headers, then referenced via `model_provider`. citeturn4view0 + +## Custom prompts (slash commands) + +- Custom prompts are Markdown files stored under `~/.codex/prompts/`. citeturn3view0 +- Custom prompts require explicit invocation and aren’t shared through the repository; use skills to share or auto-invoke. citeturn3view0 +- Prompts are invoked as `/prompts:` in the slash command UI. citeturn3view0 +- Prompt front matter supports `description:` and `argument-hint:`. citeturn3view0turn2view3 +- Prompt arguments support `$1`–`$9`, `$ARGUMENTS`, and named placeholders like `$FILE` provided as `KEY=value`. citeturn2view3 +- Codex ignores non-Markdown files in the prompts directory. citeturn2view3 + +## AGENTS.md instructions + +- Codex reads `AGENTS.md` files before doing any work and builds a combined instruction chain. citeturn3view1 +- Discovery order: global (`~/.codex`, using `AGENTS.override.md` then `AGENTS.md`) then project directory traversal from repo root to CWD, with override > AGENTS > fallback names. citeturn3view1 +- Codex concatenates files from root down; files closer to the working directory appear later and override earlier guidance. citeturn3view1 + +## Skills (Agent Skills) + +- A skill is a folder containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`. citeturn3view3turn3view4 +- `SKILL.md` uses YAML front matter and requires `name` and `description`. citeturn3view3turn3view4 +- Required fields are single-line with length limits (name ≤ 100 chars, description ≤ 500 chars). citeturn3view4 +- At startup, Codex loads only each skill’s name/description; full content is injected when invoked. citeturn3view3turn3view4 +- Skills can be repo-scoped in `.codex/skills/` or user-scoped in `~/.codex/skills/`. citeturn3view4 +- Skills can be invoked explicitly using `/skills` or `$skill-name`. citeturn3view3 + +## MCP (Model Context Protocol) + +- MCP configuration lives in `~/.codex/config.toml` and is shared by the CLI and IDE extension. citeturn3view2turn3view5 +- Each server is configured under `[mcp_servers.]`. citeturn3view5 +- STDIO servers support `command` (required), `args`, `env`, `env_vars`, and `cwd`. citeturn3view5 +- Streamable HTTP servers support `url` (required), `bearer_token_env_var`, `http_headers`, and `env_http_headers`. citeturn3view5 diff --git a/docs/specs/opencode.md b/docs/specs/opencode.md new file mode 100644 index 0000000..2d23b5e --- /dev/null +++ b/docs/specs/opencode.md @@ -0,0 +1,57 @@ +# OpenCode Spec (Config, Agents, Plugins) + +Last verified: 2026-01-21 + +## Primary sources + +``` +https://opencode.ai/docs/config +https://opencode.ai/docs/tools +https://opencode.ai/docs/permissions +https://opencode.ai/docs/plugins/ +https://opencode.ai/docs/agents/ +https://opencode.ai/config.json +``` + +## Config files and precedence + +- OpenCode supports JSON and JSONC configs. citeturn10view0 +- Config sources are merged (not replaced), with a defined precedence order from remote → global → custom → project → `.opencode` directories → inline overrides. citeturn10view0 +- Global config is stored at `~/.config/opencode/opencode.json`, and project config is `opencode.json` in the project root. citeturn10view0 +- Custom config file and directory can be provided via `OPENCODE_CONFIG` and `OPENCODE_CONFIG_DIR`. citeturn10view0 +- The `.opencode` and `~/.config/opencode` directories use plural subdirectory names (`agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, `themes/`), but singular names are also supported for backwards compatibility. citeturn10view0 + +## Core config keys + +- `model` and `small_model` set the primary and lightweight models; `provider` configures provider options. citeturn10view0 +- `tools` is still supported but deprecated; permissions are now the canonical control surface. citeturn1search0 +- `permission` controls tool approvals and can be configured globally or per tool, including pattern-based rules. citeturn1search0 +- `mcp`, `instructions`, and `disabled_providers` are supported config sections. citeturn1search5 +- `plugin` can list npm packages to load at startup. citeturn1search2 + +## Tools + +- OpenCode ships with built-in tools, and permissions determine whether each tool runs automatically, requires approval, or is denied. citeturn1search3turn1search0 +- Tools are enabled by default; permissions provide the gating mechanism. citeturn1search3 + +## Permissions + +- Permissions resolve to `allow`, `ask`, or `deny` and can be configured globally or per tool, with pattern-based rules. citeturn1search0 +- Defaults are permissive, with special cases such as `.env` file reads. citeturn1search0 +- Agent-level permissions override the global permission block. citeturn1search1turn1search0 + +## Agents + +- Agents can be configured in `opencode.json` or as markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. citeturn1search1turn10view0 +- Agent config supports `mode`, `model`, `temperature`, `tools`, and `permission`, and agent configs override global settings. citeturn1search1 +- Model IDs use the `provider/model-id` format. citeturn1search1 + +## Plugins and events + +- Local plugins are loaded from `.opencode/plugin/` (project) and `~/.config/opencode/plugin/` (global). npm plugins can be listed in `plugin` in `opencode.json`. citeturn1search2 +- Plugins are loaded in a defined order across config and plugin directories. citeturn1search2 +- Plugins export a function that returns a map of event handlers; the plugins doc lists supported event categories. citeturn1search2 + +## Notes for this repository + +- Config docs describe plural subdirectory names, while the plugins doc uses `.opencode/plugin/`. This implies singular paths remain accepted for backwards compatibility, but plural paths are the canonical structure. citeturn10view0turn1search2 diff --git a/package.json b/package.json new file mode 100644 index 0000000..4d775c2 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "@every-env/compound-plugin", + "version": "0.1.0", + "type": "module", + "private": false, + "bin": { + "compound-plugin": "./src/index.ts" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "dev": "bun run src/index.ts", + "convert": "bun run src/index.ts convert", + "list": "bun run src/index.ts list", + "cli:install": "bun run src/index.ts install", + "test": "bun test" + }, + "dependencies": { + "citty": "^0.1.6", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "bun-types": "^1.0.0" + } +} diff --git a/plugins/compound-engineering/CHANGELOG.md b/plugins/compound-engineering/CHANGELOG.md index a823d10..dd1c7f9 100644 --- a/plugins/compound-engineering/CHANGELOG.md +++ b/plugins/compound-engineering/CHANGELOG.md @@ -5,6 +5,34 @@ All notable changes to the compound-engineering plugin will be documented in thi The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.28.0] - 2026-01-21 + +### Added + +- **`/workflows:brainstorm` command** - Guided ideation flow to expand options quickly (#101) + +### Changed + +- **`/workflows:plan` command** - Smarter research decision logic before deep dives (#100) +- **Research checks** - Mandatory API deprecation validation in research flows (#102) +- **Docs** - Call out experimental OpenCode/Codex providers and install defaults +- **CLI defaults** - `install` pulls from GitHub by default and writes OpenCode/Codex output to global locations + +### Merged PRs + +- [#102](https://github.com/EveryInc/compound-engineering-plugin/pull/102) feat(research): add mandatory API deprecation validation +- [#101](https://github.com/EveryInc/compound-engineering-plugin/pull/101) feat: Add /workflows:brainstorm command and skill +- [#100](https://github.com/EveryInc/compound-engineering-plugin/pull/100) feat(workflows:plan): Add smart research decision logic + +### Contributors + +Huge thanks to the community contributors who made this release possible! 🙌 + +- **[@tmchow](https://github.com/tmchow)** - Brainstorm workflow, research decision logic (2 PRs) +- **[@jaredmorgenstern](https://github.com/jaredmorgenstern)** - API deprecation validation + +--- + ## [2.27.0] - 2026-01-20 ### Added diff --git a/plugins/compound-engineering/commands/workflows/review.md b/plugins/compound-engineering/commands/workflows/review.md index 7431c62..282c9f0 100644 --- a/plugins/compound-engineering/commands/workflows/review.md +++ b/plugins/compound-engineering/commands/workflows/review.md @@ -37,8 +37,8 @@ First, I need to determine the review target type and set up the code for analys - [ ] Determine review type: PR number (numeric), GitHub URL, file path (.md), or empty (current branch) - [ ] Check current git branch -- [ ] If ALREADY on the PR branch → proceed with analysis on current branch -- [ ] If DIFFERENT branch → offer to use worktree: "Use git-worktree skill for isolated Call `skill: git-worktree` with branch name +- [ ] If ALREADY on the target branch (PR branch, requested branch name, or the branch already checked out for review) → proceed with analysis on current branch +- [ ] If DIFFERENT branch than the review target → offer to use worktree: "Use git-worktree skill for isolated Call `skill: git-worktree` with branch name - [ ] Fetch PR metadata using `gh pr view --json` for title, body, files, linked issues - [ ] Set up language-specific analysis tools - [ ] Prepare security scanning environment diff --git a/plugins/compound-engineering/skills/git-worktree/SKILL.md b/plugins/compound-engineering/skills/git-worktree/SKILL.md index d48a6fe..1ba22f4 100644 --- a/plugins/compound-engineering/skills/git-worktree/SKILL.md +++ b/plugins/compound-engineering/skills/git-worktree/SKILL.md @@ -38,7 +38,7 @@ git worktree add .worktrees/feature-name -b feature-name main Use this skill in these scenarios: -1. **Code Review (`/workflows:review`)**: If NOT already on the PR branch, offer worktree for isolated review +1. **Code Review (`/workflows:review`)**: If NOT already on the target branch (PR branch or requested branch), offer worktree for isolated review 2. **Feature Work (`/workflows:work`)**: Always ask if user wants parallel worktree or live branch work 3. **Parallel Development**: When working on multiple features simultaneously 4. **Cleanup**: After completing work in a worktree @@ -210,8 +210,8 @@ Instead of always creating a worktree: ``` 1. Check current branch -2. If ALREADY on PR branch → stay there, no worktree needed -3. If DIFFERENT branch → offer worktree: +2. If ALREADY on target branch (PR branch or requested branch) → stay there, no worktree needed +3. If DIFFERENT branch than the review target → offer worktree: "Use worktree for isolated review? (y/n)" - yes → call git-worktree skill - no → proceed with PR diff on current branch diff --git a/src/commands/convert.ts b/src/commands/convert.ts new file mode 100644 index 0000000..91df453 --- /dev/null +++ b/src/commands/convert.ts @@ -0,0 +1,156 @@ +import { defineCommand } from "citty" +import os from "os" +import path from "path" +import { loadClaudePlugin } from "../parsers/claude" +import { targets } from "../targets" +import type { PermissionMode } from "../converters/claude-to-opencode" +import { ensureCodexAgentsFile } from "../utils/codex-agents" + +const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] + +export default defineCommand({ + meta: { + name: "convert", + description: "Convert a Claude Code plugin into another format", + }, + args: { + source: { + type: "positional", + required: true, + description: "Path to the Claude plugin directory", + }, + to: { + type: "string", + default: "opencode", + description: "Target format (opencode | codex)", + }, + output: { + type: "string", + alias: "o", + description: "Output directory (project root)", + }, + codexHome: { + type: "string", + alias: "codex-home", + description: "Write Codex output to this .codex root (ex: ~/.codex)", + }, + also: { + type: "string", + description: "Comma-separated extra targets to generate (ex: codex)", + }, + permissions: { + type: "string", + default: "broad", + description: "Permission mapping: none | broad | from-commands", + }, + agentMode: { + type: "string", + default: "subagent", + description: "Default agent mode: primary | subagent", + }, + inferTemperature: { + type: "boolean", + default: true, + description: "Infer agent temperature from name/description", + }, + }, + async run({ args }) { + const targetName = String(args.to) + const target = targets[targetName] + if (!target) { + throw new Error(`Unknown target: ${targetName}`) + } + + if (!target.implemented) { + throw new Error(`Target ${targetName} is registered but not implemented yet.`) + } + + const permissions = String(args.permissions) + if (!permissionModes.includes(permissions as PermissionMode)) { + throw new Error(`Unknown permissions mode: ${permissions}`) + } + + const plugin = await loadClaudePlugin(String(args.source)) + const outputRoot = resolveOutputRoot(args.output) + const codexHome = resolveCodexRoot(args.codexHome) + + const options = { + agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", + inferTemperature: Boolean(args.inferTemperature), + permissions: permissions as PermissionMode, + } + + const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot + const bundle = target.convert(plugin, options) + if (!bundle) { + throw new Error(`Target ${targetName} did not return a bundle.`) + } + + await target.write(primaryOutputRoot, bundle) + console.log(`Converted ${plugin.manifest.name} to ${targetName} at ${primaryOutputRoot}`) + + const extraTargets = parseExtraTargets(args.also) + const allTargets = [targetName, ...extraTargets] + for (const extra of extraTargets) { + const handler = targets[extra] + if (!handler) { + console.warn(`Skipping unknown target: ${extra}`) + continue + } + if (!handler.implemented) { + console.warn(`Skipping ${extra}: not implemented yet.`) + continue + } + const extraBundle = handler.convert(plugin, options) + if (!extraBundle) { + console.warn(`Skipping ${extra}: no output returned.`) + continue + } + const extraRoot = extra === "codex" && codexHome + ? codexHome + : path.join(outputRoot, extra) + await handler.write(extraRoot, extraBundle) + console.log(`Converted ${plugin.manifest.name} to ${extra} at ${extraRoot}`) + } + + if (allTargets.includes("codex")) { + await ensureCodexAgentsFile(codexHome) + } + }, +}) + +function parseExtraTargets(value: unknown): string[] { + if (!value) return [] + return String(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function resolveCodexHome(value: unknown): string | null { + if (!value) return null + const raw = String(value).trim() + if (!raw) return null + const expanded = expandHome(raw) + return path.resolve(expanded) +} + +function resolveCodexRoot(value: unknown): string { + return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex") +} + +function expandHome(value: string): string { + if (value === "~") return os.homedir() + if (value.startsWith(`~${path.sep}`)) { + return path.join(os.homedir(), value.slice(2)) + } + return value +} + +function resolveOutputRoot(value: unknown): string { + if (value && String(value).trim()) { + const expanded = expandHome(String(value).trim()) + return path.resolve(expanded) + } + return process.cwd() +} diff --git a/src/commands/install.ts b/src/commands/install.ts new file mode 100644 index 0000000..8ef936b --- /dev/null +++ b/src/commands/install.ts @@ -0,0 +1,221 @@ +import { defineCommand } from "citty" +import { promises as fs } from "fs" +import os from "os" +import path from "path" +import { loadClaudePlugin } from "../parsers/claude" +import { targets } from "../targets" +import { pathExists } from "../utils/files" +import type { PermissionMode } from "../converters/claude-to-opencode" +import { ensureCodexAgentsFile } from "../utils/codex-agents" + +const permissionModes: PermissionMode[] = ["none", "broad", "from-commands"] + +export default defineCommand({ + meta: { + name: "install", + description: "Install and convert a Claude plugin", + }, + args: { + plugin: { + type: "positional", + required: true, + description: "Plugin name or path", + }, + to: { + type: "string", + default: "opencode", + description: "Target format (opencode | codex)", + }, + output: { + type: "string", + alias: "o", + description: "Output directory (project root)", + }, + codexHome: { + type: "string", + alias: "codex-home", + description: "Write Codex output to this .codex root (ex: ~/.codex)", + }, + also: { + type: "string", + description: "Comma-separated extra targets to generate (ex: codex)", + }, + permissions: { + type: "string", + default: "broad", + description: "Permission mapping: none | broad | from-commands", + }, + agentMode: { + type: "string", + default: "subagent", + description: "Default agent mode: primary | subagent", + }, + inferTemperature: { + type: "boolean", + default: true, + description: "Infer agent temperature from name/description", + }, + }, + async run({ args }) { + const targetName = String(args.to) + const target = targets[targetName] + if (!target) { + throw new Error(`Unknown target: ${targetName}`) + } + if (!target.implemented) { + throw new Error(`Target ${targetName} is registered but not implemented yet.`) + } + + const permissions = String(args.permissions) + if (!permissionModes.includes(permissions as PermissionMode)) { + throw new Error(`Unknown permissions mode: ${permissions}`) + } + + const resolvedPlugin = await resolvePluginPath(String(args.plugin)) + + try { + const plugin = await loadClaudePlugin(resolvedPlugin.path) + const outputRoot = resolveOutputRoot(args.output) + const codexHome = resolveCodexRoot(args.codexHome) + + const options = { + agentMode: String(args.agentMode) === "primary" ? "primary" : "subagent", + inferTemperature: Boolean(args.inferTemperature), + permissions: permissions as PermissionMode, + } + + const bundle = target.convert(plugin, options) + if (!bundle) { + throw new Error(`Target ${targetName} did not return a bundle.`) + } + const primaryOutputRoot = targetName === "codex" && codexHome ? codexHome : outputRoot + await target.write(primaryOutputRoot, bundle) + console.log(`Installed ${plugin.manifest.name} to ${primaryOutputRoot}`) + + const extraTargets = parseExtraTargets(args.also) + const allTargets = [targetName, ...extraTargets] + for (const extra of extraTargets) { + const handler = targets[extra] + if (!handler) { + console.warn(`Skipping unknown target: ${extra}`) + continue + } + if (!handler.implemented) { + console.warn(`Skipping ${extra}: not implemented yet.`) + continue + } + const extraBundle = handler.convert(plugin, options) + if (!extraBundle) { + console.warn(`Skipping ${extra}: no output returned.`) + continue + } + const extraRoot = extra === "codex" && codexHome + ? codexHome + : path.join(outputRoot, extra) + await handler.write(extraRoot, extraBundle) + console.log(`Installed ${plugin.manifest.name} to ${extraRoot}`) + } + + if (allTargets.includes("codex")) { + await ensureCodexAgentsFile(codexHome) + } + } finally { + if (resolvedPlugin.cleanup) { + await resolvedPlugin.cleanup() + } + } + }, +}) + +type ResolvedPluginPath = { + path: string + cleanup?: () => Promise +} + +async function resolvePluginPath(input: string): Promise { + const directPath = path.resolve(input) + if (await pathExists(directPath)) return { path: directPath } + + const pluginsPath = path.join(process.cwd(), "plugins", input) + if (await pathExists(pluginsPath)) return { path: pluginsPath } + + return await resolveGitHubPluginPath(input) +} + +function parseExtraTargets(value: unknown): string[] { + if (!value) return [] + return String(value) + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean) +} + +function resolveCodexHome(value: unknown): string | null { + if (!value) return null + const raw = String(value).trim() + if (!raw) return null + const expanded = expandHome(raw) + return path.resolve(expanded) +} + +function resolveCodexRoot(value: unknown): string { + return resolveCodexHome(value) ?? path.join(os.homedir(), ".codex") +} + +function expandHome(value: string): string { + if (value === "~") return os.homedir() + if (value.startsWith(`~${path.sep}`)) { + return path.join(os.homedir(), value.slice(2)) + } + return value +} + +function resolveOutputRoot(value: unknown): string { + if (value && String(value).trim()) { + const expanded = expandHome(String(value).trim()) + return path.resolve(expanded) + } + return path.join(os.homedir(), ".opencode") +} + +async function resolveGitHubPluginPath(pluginName: string): Promise { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "compound-plugin-")) + const source = resolveGitHubSource() + try { + await cloneGitHubRepo(source, tempRoot) + } catch (error) { + await fs.rm(tempRoot, { recursive: true, force: true }) + throw error + } + + const pluginPath = path.join(tempRoot, "plugins", pluginName) + if (!(await pathExists(pluginPath))) { + await fs.rm(tempRoot, { recursive: true, force: true }) + throw new Error(`Could not find plugin ${pluginName} in ${source}.`) + } + + return { + path: pluginPath, + cleanup: async () => { + await fs.rm(tempRoot, { recursive: true, force: true }) + }, + } +} + +function resolveGitHubSource(): string { + const override = process.env.COMPOUND_PLUGIN_GITHUB_SOURCE + if (override && override.trim()) return override.trim() + return "https://github.com/EveryInc/compound-engineering-plugin" +} + +async function cloneGitHubRepo(source: string, destination: string): Promise { + const proc = Bun.spawn(["git", "clone", "--depth", "1", source, destination], { + stdout: "pipe", + stderr: "pipe", + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + if (exitCode !== 0) { + throw new Error(`Failed to clone ${source}. ${stderr.trim()}`) + } +} diff --git a/src/commands/list.ts b/src/commands/list.ts new file mode 100644 index 0000000..7b350c9 --- /dev/null +++ b/src/commands/list.ts @@ -0,0 +1,37 @@ +import path from "path" +import { promises as fs } from "fs" +import { defineCommand } from "citty" +import { pathExists } from "../utils/files" + +export default defineCommand({ + meta: { + name: "list", + description: "List available Claude plugins under plugins/", + }, + async run() { + const root = process.cwd() + const pluginsDir = path.join(root, "plugins") + if (!(await pathExists(pluginsDir))) { + console.log("No plugins directory found.") + return + } + + const entries = await fs.readdir(pluginsDir, { withFileTypes: true }) + const plugins: string[] = [] + + for (const entry of entries) { + if (!entry.isDirectory()) continue + const manifestPath = path.join(pluginsDir, entry.name, ".claude-plugin", "plugin.json") + if (await pathExists(manifestPath)) { + plugins.push(entry.name) + } + } + + if (plugins.length === 0) { + console.log("No Claude plugins found under plugins/.") + return + } + + console.log(plugins.sort().join("\n")) + }, +}) diff --git a/src/converters/claude-to-codex.ts b/src/converters/claude-to-codex.ts new file mode 100644 index 0000000..942bc9a --- /dev/null +++ b/src/converters/claude-to-codex.ts @@ -0,0 +1,124 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { ClaudeAgent, ClaudeCommand, ClaudePlugin } from "../types/claude" +import type { CodexBundle, CodexGeneratedSkill } from "../types/codex" +import type { ClaudeToOpenCodeOptions } from "./claude-to-opencode" + +export type ClaudeToCodexOptions = ClaudeToOpenCodeOptions + +const CODEX_DESCRIPTION_MAX_LENGTH = 1024 + +export function convertClaudeToCodex( + plugin: ClaudePlugin, + _options: ClaudeToCodexOptions, +): CodexBundle { + const promptNames = new Set() + const skillDirs = plugin.skills.map((skill) => ({ + name: skill.name, + sourceDir: skill.sourceDir, + })) + + const usedSkillNames = new Set(skillDirs.map((skill) => normalizeName(skill.name))) + const commandSkills: CodexGeneratedSkill[] = [] + const prompts = plugin.commands.map((command) => { + const promptName = uniqueName(normalizeName(command.name), promptNames) + const commandSkill = convertCommandSkill(command, usedSkillNames) + commandSkills.push(commandSkill) + const content = renderPrompt(command, commandSkill.name) + return { name: promptName, content } + }) + + const agentSkills = plugin.agents.map((agent) => convertAgent(agent, usedSkillNames)) + const generatedSkills = [...commandSkills, ...agentSkills] + + return { + prompts, + skillDirs, + generatedSkills, + mcpServers: plugin.mcpServers, + } +} + +function convertAgent(agent: ClaudeAgent, usedNames: Set): CodexGeneratedSkill { + const name = uniqueName(normalizeName(agent.name), usedNames) + const description = sanitizeDescription( + agent.description ?? `Converted from Claude agent ${agent.name}`, + ) + const frontmatter: Record = { name, description } + + let body = agent.body.trim() + if (agent.capabilities && agent.capabilities.length > 0) { + const capabilities = agent.capabilities.map((capability) => `- ${capability}`).join("\n") + body = `## Capabilities\n${capabilities}\n\n${body}`.trim() + } + if (body.length === 0) { + body = `Instructions converted from the ${agent.name} agent.` + } + + const content = formatFrontmatter(frontmatter, body) + return { name, content } +} + +function convertCommandSkill(command: ClaudeCommand, usedNames: Set): CodexGeneratedSkill { + const name = uniqueName(normalizeName(command.name), usedNames) + const frontmatter: Record = { + name, + description: sanitizeDescription( + command.description ?? `Converted from Claude command ${command.name}`, + ), + } + const sections: string[] = [] + if (command.argumentHint) { + sections.push(`## Arguments\n${command.argumentHint}`) + } + if (command.allowedTools && command.allowedTools.length > 0) { + sections.push(`## Allowed tools\n${command.allowedTools.map((tool) => `- ${tool}`).join("\n")}`) + } + sections.push(command.body.trim()) + const body = sections.filter(Boolean).join("\n\n").trim() + const content = formatFrontmatter(frontmatter, body.length > 0 ? body : command.body) + return { name, content } +} + +function renderPrompt(command: ClaudeCommand, skillName: string): string { + const frontmatter: Record = { + description: command.description, + "argument-hint": command.argumentHint, + } + const instructions = `Use the $${skillName} skill for this command and follow its instructions.` + const body = [instructions, "", command.body].join("\n").trim() + return formatFrontmatter(frontmatter, body) +} + +function normalizeName(value: string): string { + const trimmed = value.trim() + if (!trimmed) return "item" + const normalized = trimmed + .toLowerCase() + .replace(/[\\/]+/g, "-") + .replace(/[:\s]+/g, "-") + .replace(/[^a-z0-9_-]+/g, "-") + .replace(/-+/g, "-") + .replace(/^-+|-+$/g, "") + return normalized || "item" +} + +function sanitizeDescription(value: string, maxLength = CODEX_DESCRIPTION_MAX_LENGTH): string { + const normalized = value.replace(/\s+/g, " ").trim() + if (normalized.length <= maxLength) return normalized + const ellipsis = "..." + return normalized.slice(0, Math.max(0, maxLength - ellipsis.length)).trimEnd() + ellipsis +} + +function uniqueName(base: string, used: Set): string { + if (!used.has(base)) { + used.add(base) + return base + } + let index = 2 + while (used.has(`${base}-${index}`)) { + index += 1 + } + const name = `${base}-${index}` + used.add(name) + return name +} diff --git a/src/converters/claude-to-opencode.ts b/src/converters/claude-to-opencode.ts new file mode 100644 index 0000000..ad8cc00 --- /dev/null +++ b/src/converters/claude-to-opencode.ts @@ -0,0 +1,392 @@ +import { formatFrontmatter } from "../utils/frontmatter" +import type { + ClaudeAgent, + ClaudeCommand, + ClaudeHooks, + ClaudePlugin, + ClaudeMcpServer, +} from "../types/claude" +import type { + OpenCodeBundle, + OpenCodeCommandConfig, + OpenCodeConfig, + OpenCodeMcpServer, +} from "../types/opencode" + +export type PermissionMode = "none" | "broad" | "from-commands" + +export type ClaudeToOpenCodeOptions = { + agentMode: "primary" | "subagent" + inferTemperature: boolean + permissions: PermissionMode +} + +const TOOL_MAP: Record = { + bash: "bash", + read: "read", + write: "write", + edit: "edit", + grep: "grep", + glob: "glob", + list: "list", + webfetch: "webfetch", + skill: "skill", + patch: "patch", + task: "task", + question: "question", + todowrite: "todowrite", + todoread: "todoread", +} + +type HookEventMapping = { + events: string[] + type: "tool" | "session" | "permission" | "message" + requireError?: boolean + note?: string +} + +const HOOK_EVENT_MAP: Record = { + PreToolUse: { events: ["tool.execute.before"], type: "tool" }, + PostToolUse: { events: ["tool.execute.after"], type: "tool" }, + PostToolUseFailure: { events: ["tool.execute.after"], type: "tool", requireError: true, note: "Claude PostToolUseFailure" }, + SessionStart: { events: ["session.created"], type: "session" }, + SessionEnd: { events: ["session.deleted"], type: "session" }, + Stop: { events: ["session.idle"], type: "session" }, + PreCompact: { events: ["experimental.session.compacting"], type: "session" }, + PermissionRequest: { events: ["permission.requested", "permission.replied"], type: "permission", note: "Claude PermissionRequest" }, + UserPromptSubmit: { events: ["message.created", "message.updated"], type: "message", note: "Claude UserPromptSubmit" }, + Notification: { events: ["message.updated"], type: "message", note: "Claude Notification" }, + Setup: { events: ["session.created"], type: "session", note: "Claude Setup" }, + SubagentStart: { events: ["message.updated"], type: "message", note: "Claude SubagentStart" }, + SubagentStop: { events: ["message.updated"], type: "message", note: "Claude SubagentStop" }, +} + +export function convertClaudeToOpenCode( + plugin: ClaudePlugin, + options: ClaudeToOpenCodeOptions, +): OpenCodeBundle { + const agentFiles = plugin.agents.map((agent) => convertAgent(agent, options)) + const commandMap = convertCommands(plugin.commands) + const mcp = plugin.mcpServers ? convertMcp(plugin.mcpServers) : undefined + const plugins = plugin.hooks ? [convertHooks(plugin.hooks)] : [] + + const config: OpenCodeConfig = { + $schema: "https://opencode.ai/config.json", + command: Object.keys(commandMap).length > 0 ? commandMap : undefined, + mcp: mcp && Object.keys(mcp).length > 0 ? mcp : undefined, + } + + applyPermissions(config, plugin.commands, options.permissions) + + return { + config, + agents: agentFiles, + plugins, + skillDirs: plugin.skills.map((skill) => ({ sourceDir: skill.sourceDir, name: skill.name })), + } +} + +function convertAgent(agent: ClaudeAgent, options: ClaudeToOpenCodeOptions) { + const frontmatter: Record = { + description: agent.description, + mode: options.agentMode, + } + + if (agent.model && agent.model !== "inherit") { + frontmatter.model = normalizeModel(agent.model) + } + + if (options.inferTemperature) { + const temperature = inferTemperature(agent) + if (temperature !== undefined) { + frontmatter.temperature = temperature + } + } + + const content = formatFrontmatter(frontmatter, agent.body) + + return { + name: agent.name, + content, + } +} + +function convertCommands(commands: ClaudeCommand[]): Record { + const result: Record = {} + for (const command of commands) { + const entry: OpenCodeCommandConfig = { + description: command.description, + template: command.body, + } + if (command.model && command.model !== "inherit") { + entry.model = normalizeModel(command.model) + } + result[command.name] = entry + } + return result +} + +function convertMcp(servers: Record): Record { + const result: Record = {} + for (const [name, server] of Object.entries(servers)) { + if (server.command) { + result[name] = { + type: "local", + command: [server.command, ...(server.args ?? [])], + environment: server.env, + enabled: true, + } + continue + } + + if (server.url) { + result[name] = { + type: "remote", + url: server.url, + headers: server.headers, + enabled: true, + } + } + } + return result +} + +function convertHooks(hooks: ClaudeHooks) { + const handlerBlocks: string[] = [] + const hookMap = hooks.hooks + const unmappedEvents: string[] = [] + + for (const [eventName, matchers] of Object.entries(hookMap)) { + const mapping = HOOK_EVENT_MAP[eventName] + if (!mapping) { + unmappedEvents.push(eventName) + continue + } + if (matchers.length === 0) continue + for (const event of mapping.events) { + handlerBlocks.push( + renderHookHandlers(event, matchers, { + useToolMatcher: mapping.type === "tool" || mapping.type === "permission", + requireError: mapping.requireError ?? false, + note: mapping.note, + }), + ) + } + } + + const unmappedComment = unmappedEvents.length > 0 + ? `// Unmapped Claude hook events: ${unmappedEvents.join(", ")}\n` + : "" + + const content = `${unmappedComment}import type { Plugin } from "@opencode-ai/plugin"\n\nexport const ConvertedHooks: Plugin = async ({ $ }) => {\n return {\n${handlerBlocks.join(",\n")}\n }\n}\n\nexport default ConvertedHooks\n` + + return { + name: "converted-hooks.ts", + content, + } +} + +function renderHookHandlers( + event: string, + matchers: ClaudeHooks["hooks"][string], + options: { useToolMatcher: boolean; requireError: boolean; note?: string }, +) { + const statements: string[] = [] + for (const matcher of matchers) { + statements.push(...renderHookStatements(matcher, options.useToolMatcher)) + } + const rendered = statements.map((line) => ` ${line}`).join("\n") + const wrapped = options.requireError + ? ` if (input?.error) {\n${statements.map((line) => ` ${line}`).join("\n")}\n }` + : rendered + const note = options.note ? ` // ${options.note}\n` : "" + return ` "${event}": async (input) => {\n${note}${wrapped}\n }` +} + +function renderHookStatements( + matcher: ClaudeHooks["hooks"][string][number], + useToolMatcher: boolean, +): string[] { + if (!matcher.hooks || matcher.hooks.length === 0) return [] + const tools = matcher.matcher + .split("|") + .map((tool) => tool.trim().toLowerCase()) + .filter(Boolean) + + const useMatcher = useToolMatcher && tools.length > 0 && !tools.includes("*") + const condition = useMatcher + ? tools.map((tool) => `input.tool === "${tool}"`).join(" || ") + : null + const statements: string[] = [] + + for (const hook of matcher.hooks) { + if (hook.type === "command") { + if (condition) { + statements.push(`if (${condition}) { await $\`${hook.command}\` }`) + } else { + statements.push(`await $\`${hook.command}\``) + } + if (hook.timeout) { + statements.push(`// timeout: ${hook.timeout}s (not enforced)`) + } + continue + } + if (hook.type === "prompt") { + statements.push(`// Prompt hook for ${matcher.matcher}: ${hook.prompt.replace(/\n/g, " ")}`) + continue + } + statements.push(`// Agent hook for ${matcher.matcher}: ${hook.agent}`) + } + + return statements +} + +function normalizeModel(model: string): string { + if (model.includes("/")) return model + if (/^claude-/.test(model)) return `anthropic/${model}` + if (/^(gpt-|o1-|o3-)/.test(model)) return `openai/${model}` + if (/^gemini-/.test(model)) return `google/${model}` + return `anthropic/${model}` +} + +function inferTemperature(agent: ClaudeAgent): number | undefined { + const sample = `${agent.name} ${agent.description ?? ""}`.toLowerCase() + if (/(review|audit|security|sentinel|oracle|lint|verification|guardian)/.test(sample)) { + return 0.1 + } + if (/(plan|planning|architecture|strategist|analysis|research)/.test(sample)) { + return 0.2 + } + if (/(doc|readme|changelog|editor|writer)/.test(sample)) { + return 0.3 + } + if (/(brainstorm|creative|ideate|design|concept)/.test(sample)) { + return 0.6 + } + return 0.3 +} + +function applyPermissions( + config: OpenCodeConfig, + commands: ClaudeCommand[], + mode: PermissionMode, +) { + if (mode === "none") return + + const sourceTools = [ + "read", + "write", + "edit", + "bash", + "grep", + "glob", + "list", + "webfetch", + "skill", + "patch", + "task", + "question", + "todowrite", + "todoread", + ] + let enabled = new Set() + const patterns: Record> = {} + + if (mode === "broad") { + enabled = new Set(sourceTools) + } else { + for (const command of commands) { + if (!command.allowedTools) continue + for (const tool of command.allowedTools) { + const parsed = parseToolSpec(tool) + if (!parsed.tool) continue + enabled.add(parsed.tool) + if (parsed.pattern) { + const normalizedPattern = normalizePattern(parsed.tool, parsed.pattern) + if (!patterns[parsed.tool]) patterns[parsed.tool] = new Set() + patterns[parsed.tool].add(normalizedPattern) + } + } + } + } + + const permission: Record = {} + const tools: Record = {} + + for (const tool of sourceTools) { + tools[tool] = mode === "broad" ? true : enabled.has(tool) + } + + if (mode === "broad") { + for (const tool of sourceTools) { + permission[tool] = "allow" + } + } else { + for (const tool of sourceTools) { + const toolPatterns = patterns[tool] + if (toolPatterns && toolPatterns.size > 0) { + const patternPermission: Record = { "*": "deny" } + for (const pattern of toolPatterns) { + patternPermission[pattern] = "allow" + } + ;(permission as Record)[tool] = patternPermission + } else { + permission[tool] = enabled.has(tool) ? "allow" : "deny" + } + } + } + + if (mode !== "broad") { + for (const [tool, toolPatterns] of Object.entries(patterns)) { + if (!toolPatterns || toolPatterns.size === 0) continue + const patternPermission: Record = { "*": "deny" } + for (const pattern of toolPatterns) { + patternPermission[pattern] = "allow" + } + ;(permission as Record)[tool] = patternPermission + } + } + + if (enabled.has("write") || enabled.has("edit")) { + if (typeof permission.edit === "string") permission.edit = "allow" + if (typeof permission.write === "string") permission.write = "allow" + } + if (patterns.write || patterns.edit) { + const combined = new Set() + for (const pattern of patterns.write ?? []) combined.add(pattern) + for (const pattern of patterns.edit ?? []) combined.add(pattern) + const combinedPermission: Record = { "*": "deny" } + for (const pattern of combined) { + combinedPermission[pattern] = "allow" + } + ;(permission as Record).edit = combinedPermission + ;(permission as Record).write = combinedPermission + } + + config.permission = permission + config.tools = tools +} + +function normalizeTool(raw: string): string | null { + return parseToolSpec(raw).tool +} + +function parseToolSpec(raw: string): { tool: string | null; pattern?: string } { + const trimmed = raw.trim() + if (!trimmed) return { tool: null } + const [namePart, patternPart] = trimmed.split("(", 2) + const name = namePart.trim().toLowerCase() + const tool = TOOL_MAP[name] ?? null + if (!patternPart) return { tool } + const normalizedPattern = patternPart.endsWith(")") + ? patternPart.slice(0, -1).trim() + : patternPart.trim() + return { tool, pattern: normalizedPattern } +} + +function normalizePattern(tool: string, pattern: string): string { + if (tool === "bash") { + return pattern.replace(/:/g, " ").trim() + } + return pattern +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..49c5774 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,20 @@ +#!/usr/bin/env bun +import { defineCommand, runMain } from "citty" +import convert from "./commands/convert" +import install from "./commands/install" +import listCommand from "./commands/list" + +const main = defineCommand({ + meta: { + name: "compound-plugin", + version: "0.1.0", + description: "Convert Claude Code plugins into other agent formats", + }, + subCommands: { + convert: () => convert, + install: () => install, + list: () => listCommand, + }, +}) + +runMain(main) diff --git a/src/parsers/claude.ts b/src/parsers/claude.ts new file mode 100644 index 0000000..0493e75 --- /dev/null +++ b/src/parsers/claude.ts @@ -0,0 +1,248 @@ +import path from "path" +import { parseFrontmatter } from "../utils/frontmatter" +import { readJson, readText, pathExists, walkFiles } from "../utils/files" +import type { + ClaudeAgent, + ClaudeCommand, + ClaudeHooks, + ClaudeManifest, + ClaudeMcpServer, + ClaudePlugin, + ClaudeSkill, +} from "../types/claude" + +const PLUGIN_MANIFEST = path.join(".claude-plugin", "plugin.json") + +export async function loadClaudePlugin(inputPath: string): Promise { + const root = await resolveClaudeRoot(inputPath) + const manifestPath = path.join(root, PLUGIN_MANIFEST) + const manifest = await readJson(manifestPath) + + const agents = await loadAgents(resolveComponentDirs(root, "agents", manifest.agents)) + const commands = await loadCommands(resolveComponentDirs(root, "commands", manifest.commands)) + const skills = await loadSkills(resolveComponentDirs(root, "skills", manifest.skills)) + const hooks = await loadHooks(root, manifest.hooks) + + const mcpServers = await loadMcpServers(root, manifest) + + return { + root, + manifest, + agents, + commands, + skills, + hooks, + mcpServers, + } +} + +async function resolveClaudeRoot(inputPath: string): Promise { + const absolute = path.resolve(inputPath) + const manifestAtPath = path.join(absolute, PLUGIN_MANIFEST) + if (await pathExists(manifestAtPath)) { + return absolute + } + + if (absolute.endsWith(PLUGIN_MANIFEST)) { + return path.dirname(path.dirname(absolute)) + } + + if (absolute.endsWith("plugin.json")) { + return path.dirname(path.dirname(absolute)) + } + + throw new Error(`Could not find ${PLUGIN_MANIFEST} under ${inputPath}`) +} + +async function loadAgents(agentsDirs: string[]): Promise { + const files = await collectMarkdownFiles(agentsDirs) + + const agents: ClaudeAgent[] = [] + for (const file of files) { + const raw = await readText(file) + const { data, body } = parseFrontmatter(raw) + const name = (data.name as string) ?? path.basename(file, ".md") + agents.push({ + name, + description: data.description as string | undefined, + capabilities: data.capabilities as string[] | undefined, + model: data.model as string | undefined, + body: body.trim(), + sourcePath: file, + }) + } + return agents +} + +async function loadCommands(commandsDirs: string[]): Promise { + const files = await collectMarkdownFiles(commandsDirs) + + const commands: ClaudeCommand[] = [] + for (const file of files) { + const raw = await readText(file) + const { data, body } = parseFrontmatter(raw) + const name = (data.name as string) ?? path.basename(file, ".md") + const allowedTools = parseAllowedTools(data["allowed-tools"]) + commands.push({ + name, + description: data.description as string | undefined, + argumentHint: data["argument-hint"] as string | undefined, + model: data.model as string | undefined, + allowedTools, + body: body.trim(), + sourcePath: file, + }) + } + return commands +} + +async function loadSkills(skillsDirs: string[]): Promise { + const entries = await collectFiles(skillsDirs) + const skillFiles = entries.filter((file) => path.basename(file) === "SKILL.md") + const skills: ClaudeSkill[] = [] + for (const file of skillFiles) { + const raw = await readText(file) + const { data } = parseFrontmatter(raw) + const name = (data.name as string) ?? path.basename(path.dirname(file)) + skills.push({ + name, + description: data.description as string | undefined, + sourceDir: path.dirname(file), + skillPath: file, + }) + } + return skills +} + +async function loadHooks(root: string, hooksField?: ClaudeManifest["hooks"]): Promise { + const hookConfigs: ClaudeHooks[] = [] + + const defaultPath = path.join(root, "hooks", "hooks.json") + if (await pathExists(defaultPath)) { + hookConfigs.push(await readJson(defaultPath)) + } + + if (hooksField) { + if (typeof hooksField === "string" || Array.isArray(hooksField)) { + const hookPaths = toPathList(hooksField) + for (const hookPath of hookPaths) { + const resolved = resolveWithinRoot(root, hookPath, "hooks path") + if (await pathExists(resolved)) { + hookConfigs.push(await readJson(resolved)) + } + } + } else { + hookConfigs.push(hooksField) + } + } + + if (hookConfigs.length === 0) return undefined + return mergeHooks(hookConfigs) +} + +async function loadMcpServers( + root: string, + manifest: ClaudeManifest, +): Promise | undefined> { + const field = manifest.mcpServers + if (field) { + if (typeof field === "string" || Array.isArray(field)) { + return mergeMcpConfigs(await loadMcpPaths(root, field)) + } + return field as Record + } + + const mcpPath = path.join(root, ".mcp.json") + if (await pathExists(mcpPath)) { + return readJson>(mcpPath) + } + + return undefined +} + +function parseAllowedTools(value: unknown): string[] | undefined { + if (!value) return undefined + if (Array.isArray(value)) { + return value.map((item) => String(item)) + } + if (typeof value === "string") { + return value + .split(/,/) + .map((item) => item.trim()) + .filter(Boolean) + } + return undefined +} + +function resolveComponentDirs( + root: string, + defaultDir: string, + custom?: string | string[], +): string[] { + const dirs = [path.join(root, defaultDir)] + for (const entry of toPathList(custom)) { + dirs.push(resolveWithinRoot(root, entry, `${defaultDir} path`)) + } + return dirs +} + +function toPathList(value?: string | string[]): string[] { + if (!value) return [] + if (Array.isArray(value)) return value + return [value] +} + +async function collectMarkdownFiles(dirs: string[]): Promise { + const entries = await collectFiles(dirs) + return entries.filter((file) => file.endsWith(".md")) +} + +async function collectFiles(dirs: string[]): Promise { + const files: string[] = [] + for (const dir of dirs) { + if (!(await pathExists(dir))) continue + const entries = await walkFiles(dir) + files.push(...entries) + } + return files +} + +function mergeHooks(hooksList: ClaudeHooks[]): ClaudeHooks { + const merged: ClaudeHooks = { hooks: {} } + for (const hooks of hooksList) { + for (const [event, matchers] of Object.entries(hooks.hooks)) { + if (!merged.hooks[event]) { + merged.hooks[event] = [] + } + merged.hooks[event].push(...matchers) + } + } + return merged +} + +async function loadMcpPaths( + root: string, + value: string | string[], +): Promise[]> { + const configs: Record[] = [] + for (const entry of toPathList(value)) { + const resolved = resolveWithinRoot(root, entry, "mcpServers path") + if (await pathExists(resolved)) { + configs.push(await readJson>(resolved)) + } + } + return configs +} + +function mergeMcpConfigs(configs: Record[]): Record { + return configs.reduce((acc, config) => ({ ...acc, ...config }), {}) +} + +function resolveWithinRoot(root: string, entry: string, label: string): string { + const resolvedRoot = path.resolve(root) + const resolvedPath = path.resolve(root, entry) + if (resolvedPath === resolvedRoot || resolvedPath.startsWith(resolvedRoot + path.sep)) { + return resolvedPath + } + throw new Error(`Invalid ${label}: ${entry}. Paths must stay within the plugin root.`) +} diff --git a/src/targets/codex.ts b/src/targets/codex.ts new file mode 100644 index 0000000..f38a4dd --- /dev/null +++ b/src/targets/codex.ts @@ -0,0 +1,91 @@ +import path from "path" +import { copyDir, ensureDir, writeText } from "../utils/files" +import type { CodexBundle } from "../types/codex" +import type { ClaudeMcpServer } from "../types/claude" + +export async function writeCodexBundle(outputRoot: string, bundle: CodexBundle): Promise { + const codexRoot = resolveCodexRoot(outputRoot) + await ensureDir(codexRoot) + + if (bundle.prompts.length > 0) { + const promptsDir = path.join(codexRoot, "prompts") + for (const prompt of bundle.prompts) { + await writeText(path.join(promptsDir, `${prompt.name}.md`), prompt.content + "\n") + } + } + + if (bundle.skillDirs.length > 0) { + const skillsRoot = path.join(codexRoot, "skills") + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name)) + } + } + + if (bundle.generatedSkills.length > 0) { + const skillsRoot = path.join(codexRoot, "skills") + for (const skill of bundle.generatedSkills) { + await writeText(path.join(skillsRoot, skill.name, "SKILL.md"), skill.content + "\n") + } + } + + const config = renderCodexConfig(bundle.mcpServers) + if (config) { + await writeText(path.join(codexRoot, "config.toml"), config) + } +} + +function resolveCodexRoot(outputRoot: string): string { + return path.basename(outputRoot) === ".codex" ? outputRoot : path.join(outputRoot, ".codex") +} + +export function renderCodexConfig(mcpServers?: Record): string | null { + if (!mcpServers || Object.keys(mcpServers).length === 0) return null + + const lines: string[] = ["# Generated by compound-plugin", ""] + + for (const [name, server] of Object.entries(mcpServers)) { + const key = formatTomlKey(name) + lines.push(`[mcp_servers.${key}]`) + + if (server.command) { + lines.push(`command = ${formatTomlString(server.command)}`) + if (server.args && server.args.length > 0) { + const args = server.args.map((arg) => formatTomlString(arg)).join(", ") + lines.push(`args = [${args}]`) + } + + if (server.env && Object.keys(server.env).length > 0) { + lines.push("") + lines.push(`[mcp_servers.${key}.env]`) + for (const [envKey, value] of Object.entries(server.env)) { + lines.push(`${formatTomlKey(envKey)} = ${formatTomlString(value)}`) + } + } + } else if (server.url) { + lines.push(`url = ${formatTomlString(server.url)}`) + if (server.headers && Object.keys(server.headers).length > 0) { + lines.push(`http_headers = ${formatTomlInlineTable(server.headers)}`) + } + } + + lines.push("") + } + + return lines.join("\n") +} + +function formatTomlString(value: string): string { + return JSON.stringify(value) +} + +function formatTomlKey(value: string): string { + if (/^[A-Za-z0-9_-]+$/.test(value)) return value + return JSON.stringify(value) +} + +function formatTomlInlineTable(entries: Record): string { + const parts = Object.entries(entries).map( + ([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`, + ) + return `{ ${parts.join(", ")} }` +} diff --git a/src/targets/index.ts b/src/targets/index.ts new file mode 100644 index 0000000..f84b5af --- /dev/null +++ b/src/targets/index.ts @@ -0,0 +1,29 @@ +import type { ClaudePlugin } from "../types/claude" +import type { OpenCodeBundle } from "../types/opencode" +import type { CodexBundle } from "../types/codex" +import { convertClaudeToOpenCode, type ClaudeToOpenCodeOptions } from "../converters/claude-to-opencode" +import { convertClaudeToCodex } from "../converters/claude-to-codex" +import { writeOpenCodeBundle } from "./opencode" +import { writeCodexBundle } from "./codex" + +export type TargetHandler = { + name: string + implemented: boolean + convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null + write: (outputRoot: string, bundle: TBundle) => Promise +} + +export const targets: Record = { + opencode: { + name: "opencode", + implemented: true, + convert: convertClaudeToOpenCode, + write: writeOpenCodeBundle, + }, + codex: { + name: "codex", + implemented: true, + convert: convertClaudeToCodex as TargetHandler["convert"], + write: writeCodexBundle as TargetHandler["write"], + }, +} diff --git a/src/targets/opencode.ts b/src/targets/opencode.ts new file mode 100644 index 0000000..ee3666b --- /dev/null +++ b/src/targets/opencode.ts @@ -0,0 +1,48 @@ +import path from "path" +import { copyDir, ensureDir, writeJson, writeText } from "../utils/files" +import type { OpenCodeBundle } from "../types/opencode" + +export async function writeOpenCodeBundle(outputRoot: string, bundle: OpenCodeBundle): Promise { + const paths = resolveOpenCodePaths(outputRoot) + await ensureDir(paths.root) + await writeJson(paths.configPath, bundle.config) + + const agentsDir = paths.agentsDir + for (const agent of bundle.agents) { + await writeText(path.join(agentsDir, `${agent.name}.md`), agent.content + "\n") + } + + if (bundle.plugins.length > 0) { + const pluginsDir = paths.pluginsDir + for (const plugin of bundle.plugins) { + await writeText(path.join(pluginsDir, plugin.name), plugin.content + "\n") + } + } + + if (bundle.skillDirs.length > 0) { + const skillsRoot = paths.skillsDir + for (const skill of bundle.skillDirs) { + await copyDir(skill.sourceDir, path.join(skillsRoot, skill.name)) + } + } +} + +function resolveOpenCodePaths(outputRoot: string) { + if (path.basename(outputRoot) === ".opencode") { + return { + root: outputRoot, + configPath: path.join(outputRoot, "opencode.json"), + agentsDir: path.join(outputRoot, "agents"), + pluginsDir: path.join(outputRoot, "plugins"), + skillsDir: path.join(outputRoot, "skills"), + } + } + + return { + root: outputRoot, + configPath: path.join(outputRoot, "opencode.json"), + agentsDir: path.join(outputRoot, ".opencode", "agents"), + pluginsDir: path.join(outputRoot, ".opencode", "plugins"), + skillsDir: path.join(outputRoot, ".opencode", "skills"), + } +} diff --git a/src/types/claude.ts b/src/types/claude.ts new file mode 100644 index 0000000..4b1c050 --- /dev/null +++ b/src/types/claude.ts @@ -0,0 +1,88 @@ +export type ClaudeMcpServer = { + type?: string + command?: string + args?: string[] + url?: string + env?: Record + headers?: Record +} + +export type ClaudeManifest = { + name: string + version: string + description?: string + author?: { + name?: string + email?: string + url?: string + } + keywords?: string[] + agents?: string | string[] + commands?: string | string[] + skills?: string | string[] + hooks?: string | string[] | ClaudeHooks + mcpServers?: Record | string | string[] +} + +export type ClaudeAgent = { + name: string + description?: string + capabilities?: string[] + model?: string + body: string + sourcePath: string +} + +export type ClaudeCommand = { + name: string + description?: string + argumentHint?: string + model?: string + allowedTools?: string[] + body: string + sourcePath: string +} + +export type ClaudeSkill = { + name: string + description?: string + sourceDir: string + skillPath: string +} + +export type ClaudePlugin = { + root: string + manifest: ClaudeManifest + agents: ClaudeAgent[] + commands: ClaudeCommand[] + skills: ClaudeSkill[] + hooks?: ClaudeHooks + mcpServers?: Record +} + +export type ClaudeHookCommand = { + type: "command" + command: string + timeout?: number +} + +export type ClaudeHookPrompt = { + type: "prompt" + prompt: string +} + +export type ClaudeHookAgent = { + type: "agent" + agent: string +} + +export type ClaudeHookEntry = ClaudeHookCommand | ClaudeHookPrompt | ClaudeHookAgent + +export type ClaudeHookMatcher = { + matcher: string + hooks: ClaudeHookEntry[] +} + +export type ClaudeHooks = { + hooks: Record +} diff --git a/src/types/codex.ts b/src/types/codex.ts new file mode 100644 index 0000000..edf0d94 --- /dev/null +++ b/src/types/codex.ts @@ -0,0 +1,23 @@ +import type { ClaudeMcpServer } from "./claude" + +export type CodexPrompt = { + name: string + content: string +} + +export type CodexSkillDir = { + name: string + sourceDir: string +} + +export type CodexGeneratedSkill = { + name: string + content: string +} + +export type CodexBundle = { + prompts: CodexPrompt[] + skillDirs: CodexSkillDir[] + generatedSkills: CodexGeneratedSkill[] + mcpServers?: Record +} diff --git a/src/types/opencode.ts b/src/types/opencode.ts new file mode 100644 index 0000000..0338892 --- /dev/null +++ b/src/types/opencode.ts @@ -0,0 +1,54 @@ +export type OpenCodePermission = "allow" | "ask" | "deny" + +export type OpenCodeConfig = { + $schema?: string + model?: string + default_agent?: string + tools?: Record + permission?: Record> + agent?: Record + command?: Record + mcp?: Record +} + +export type OpenCodeAgentConfig = { + description?: string + mode?: "primary" | "subagent" + model?: string + temperature?: number + tools?: Record + permission?: Record +} + +export type OpenCodeCommandConfig = { + description?: string + model?: string + agent?: string + template: string +} + +export type OpenCodeMcpServer = { + type: "local" | "remote" + command?: string[] + url?: string + environment?: Record + headers?: Record + enabled?: boolean +} + +export type OpenCodeAgentFile = { + name: string + content: string +} + +export type OpenCodePluginFile = { + name: string + content: string +} + +export type OpenCodeBundle = { + config: OpenCodeConfig + agents: OpenCodeAgentFile[] + plugins: OpenCodePluginFile[] + skillDirs: { sourceDir: string; name: string }[] +} diff --git a/src/utils/codex-agents.ts b/src/utils/codex-agents.ts new file mode 100644 index 0000000..620e1ce --- /dev/null +++ b/src/utils/codex-agents.ts @@ -0,0 +1,64 @@ +import path from "path" +import { ensureDir, pathExists, readText, writeText } from "./files" + +export const CODEX_AGENTS_BLOCK_START = "" +export const CODEX_AGENTS_BLOCK_END = "" + +const CODEX_AGENTS_BLOCK_BODY = `## Compound Codex Tool Mapping (Claude Compatibility) + +This section maps Claude Code plugin tool references to Codex behavior. +Only this block is managed automatically. + +Tool mapping: +- Read: use shell reads (cat/sed) or rg +- Write: create files via shell redirection or apply_patch +- Edit/MultiEdit: use apply_patch +- Bash: use shell_command +- Grep: use rg (fallback: grep) +- Glob: use rg --files or find +- LS: use ls via shell_command +- WebFetch/WebSearch: use curl or Context7 for library docs +- AskUserQuestion/Question: ask the user in chat +- Task/Subagent/Parallel: run sequentially in main thread; use multi_tool_use.parallel for tool calls +- TodoWrite/TodoRead: use file-based todos in todos/ with file-todos skill +- Skill: open the referenced SKILL.md and follow it +- ExitPlanMode: ignore +` + +export async function ensureCodexAgentsFile(codexHome: string): Promise { + await ensureDir(codexHome) + const filePath = path.join(codexHome, "AGENTS.md") + const block = buildCodexAgentsBlock() + + if (!(await pathExists(filePath))) { + await writeText(filePath, block + "\n") + return + } + + const existing = await readText(filePath) + const updated = upsertBlock(existing, block) + if (updated !== existing) { + await writeText(filePath, updated) + } +} + +function buildCodexAgentsBlock(): string { + return [CODEX_AGENTS_BLOCK_START, CODEX_AGENTS_BLOCK_BODY.trim(), CODEX_AGENTS_BLOCK_END].join("\n") +} + +function upsertBlock(existing: string, block: string): string { + const startIndex = existing.indexOf(CODEX_AGENTS_BLOCK_START) + const endIndex = existing.indexOf(CODEX_AGENTS_BLOCK_END) + + if (startIndex !== -1 && endIndex !== -1 && endIndex > startIndex) { + const before = existing.slice(0, startIndex).trimEnd() + const after = existing.slice(endIndex + CODEX_AGENTS_BLOCK_END.length).trimStart() + return [before, block, after].filter(Boolean).join("\n\n") + "\n" + } + + if (existing.trim().length === 0) { + return block + "\n" + } + + return existing.trimEnd() + "\n\n" + block + "\n" +} diff --git a/src/utils/files.ts b/src/utils/files.ts new file mode 100644 index 0000000..5fd1453 --- /dev/null +++ b/src/utils/files.ts @@ -0,0 +1,64 @@ +import { promises as fs } from "fs" +import path from "path" + +export async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +export async function ensureDir(dirPath: string): Promise { + await fs.mkdir(dirPath, { recursive: true }) +} + +export async function readText(filePath: string): Promise { + return fs.readFile(filePath, "utf8") +} + +export async function readJson(filePath: string): Promise { + const raw = await readText(filePath) + return JSON.parse(raw) as T +} + +export async function writeText(filePath: string, content: string): Promise { + await ensureDir(path.dirname(filePath)) + await fs.writeFile(filePath, content, "utf8") +} + +export async function writeJson(filePath: string, data: unknown): Promise { + const content = JSON.stringify(data, null, 2) + await writeText(filePath, content + "\n") +} + +export async function walkFiles(root: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }) + const results: string[] = [] + for (const entry of entries) { + const fullPath = path.join(root, entry.name) + if (entry.isDirectory()) { + const nested = await walkFiles(fullPath) + results.push(...nested) + } else if (entry.isFile()) { + results.push(fullPath) + } + } + return results +} + +export async function copyDir(sourceDir: string, targetDir: string): Promise { + await ensureDir(targetDir) + const entries = await fs.readdir(sourceDir, { withFileTypes: true }) + for (const entry of entries) { + const sourcePath = path.join(sourceDir, entry.name) + const targetPath = path.join(targetDir, entry.name) + if (entry.isDirectory()) { + await copyDir(sourcePath, targetPath) + } else if (entry.isFile()) { + await ensureDir(path.dirname(targetPath)) + await fs.copyFile(sourcePath, targetPath) + } + } +} diff --git a/src/utils/frontmatter.ts b/src/utils/frontmatter.ts new file mode 100644 index 0000000..a799c94 --- /dev/null +++ b/src/utils/frontmatter.ts @@ -0,0 +1,65 @@ +import { load } from "js-yaml" + +export type FrontmatterResult = { + data: Record + body: string +} + +export function parseFrontmatter(raw: string): FrontmatterResult { + const lines = raw.split(/\r?\n/) + if (lines.length === 0 || lines[0].trim() !== "---") { + return { data: {}, body: raw } + } + + let endIndex = -1 + for (let i = 1; i < lines.length; i += 1) { + if (lines[i].trim() === "---") { + endIndex = i + break + } + } + + if (endIndex === -1) { + return { data: {}, body: raw } + } + + const yamlText = lines.slice(1, endIndex).join("\n") + const body = lines.slice(endIndex + 1).join("\n") + const parsed = load(yamlText) + const data = (parsed && typeof parsed === "object") ? (parsed as Record) : {} + return { data, body } +} + +export function formatFrontmatter(data: Record, body: string): string { + const yaml = Object.entries(data) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => formatYamlLine(key, value)) + .join("\n") + + if (yaml.trim().length === 0) { + return body + } + + return [`---`, yaml, `---`, "", body].join("\n") +} + +function formatYamlLine(key: string, value: unknown): string { + if (Array.isArray(value)) { + const items = value.map((item) => ` - ${formatYamlValue(item)}`) + return [key + ":", ...items].join("\n") + } + return `${key}: ${formatYamlValue(value)}` +} + +function formatYamlValue(value: unknown): string { + if (value === null || value === undefined) return "" + if (typeof value === "number" || typeof value === "boolean") return String(value) + const raw = String(value) + if (raw.includes("\n")) { + return `|\n${raw.split("\n").map((line) => ` ${line}`).join("\n")}` + } + if (raw.includes(":") || raw.startsWith("[") || raw.startsWith("{")) { + return JSON.stringify(raw) + } + return raw +} diff --git a/tests/claude-parser.test.ts b/tests/claude-parser.test.ts new file mode 100644 index 0000000..791f287 --- /dev/null +++ b/tests/claude-parser.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { loadClaudePlugin } from "../src/parsers/claude" + +const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") +const mcpFixtureRoot = path.join(import.meta.dir, "fixtures", "mcp-file") +const customPathsRoot = path.join(import.meta.dir, "fixtures", "custom-paths") +const invalidCommandPathRoot = path.join(import.meta.dir, "fixtures", "invalid-command-path") +const invalidHooksPathRoot = path.join(import.meta.dir, "fixtures", "invalid-hooks-path") +const invalidMcpPathRoot = path.join(import.meta.dir, "fixtures", "invalid-mcp-path") + +describe("loadClaudePlugin", () => { + test("loads manifest, agents, commands, skills, hooks", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + + expect(plugin.manifest.name).toBe("compound-engineering") + expect(plugin.agents.length).toBe(2) + expect(plugin.commands.length).toBe(6) + expect(plugin.skills.length).toBe(1) + expect(plugin.hooks).toBeDefined() + expect(plugin.mcpServers).toBeDefined() + + const researchAgent = plugin.agents.find((agent) => agent.name === "repo-research-analyst") + expect(researchAgent?.capabilities).toEqual(["Capability A", "Capability B"]) + + const reviewCommand = plugin.commands.find((command) => command.name === "workflows:review") + expect(reviewCommand?.allowedTools).toEqual([ + "Read", + "Write", + "Edit", + "Bash(ls:*)", + "Bash(git:*)", + "Grep", + "Glob", + "List", + "Patch", + "Task", + ]) + + const planReview = plugin.commands.find((command) => command.name === "plan_review") + expect(planReview?.allowedTools).toEqual(["Read", "Edit"]) + + const skillCommand = plugin.commands.find((command) => command.name === "create-agent-skill") + expect(skillCommand?.allowedTools).toEqual(["Skill(create-agent-skills)"]) + + const modelCommand = plugin.commands.find((command) => command.name === "workflows:work") + expect(modelCommand?.allowedTools).toEqual(["WebFetch"]) + + const patternCommand = plugin.commands.find((command) => command.name === "report-bug") + expect(patternCommand?.allowedTools).toEqual(["Read(.env)", "Bash(git:*)"]) + + const planCommand = plugin.commands.find((command) => command.name === "workflows:plan") + expect(planCommand?.allowedTools).toEqual(["Question", "TodoWrite", "TodoRead"]) + + expect(plugin.mcpServers?.context7?.url).toBe("https://mcp.context7.com/mcp") + }) + + test("loads MCP servers from .mcp.json when manifest is empty", async () => { + const plugin = await loadClaudePlugin(mcpFixtureRoot) + expect(plugin.mcpServers?.remote?.url).toBe("https://example.com/stream") + }) + + test("merges default and custom component paths", async () => { + const plugin = await loadClaudePlugin(customPathsRoot) + expect(plugin.agents.map((agent) => agent.name).sort()).toEqual(["custom-agent", "default-agent"]) + expect(plugin.commands.map((command) => command.name).sort()).toEqual(["custom-command", "default-command"]) + expect(plugin.skills.map((skill) => skill.name).sort()).toEqual(["custom-skill", "default-skill"]) + expect(plugin.hooks?.hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("echo default") + expect(plugin.hooks?.hooks.PostToolUse?.[0]?.hooks[0]?.command).toBe("echo custom") + }) + + test("rejects custom component paths that escape the plugin root", async () => { + await expect(loadClaudePlugin(invalidCommandPathRoot)).rejects.toThrow( + "Invalid commands path: ../outside-commands. Paths must stay within the plugin root.", + ) + }) + + test("rejects hook paths that escape the plugin root", async () => { + await expect(loadClaudePlugin(invalidHooksPathRoot)).rejects.toThrow( + "Invalid hooks path: ../outside-hooks.json. Paths must stay within the plugin root.", + ) + }) + + test("rejects MCP paths that escape the plugin root", async () => { + await expect(loadClaudePlugin(invalidMcpPathRoot)).rejects.toThrow( + "Invalid mcpServers path: ../outside-mcp.json. Paths must stay within the plugin root.", + ) + }) +}) diff --git a/tests/cli.test.ts b/tests/cli.test.ts new file mode 100644 index 0000000..18c89b9 --- /dev/null +++ b/tests/cli.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +async function runGit(args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise { + const proc = Bun.spawn(["git", ...args], { + cwd, + stdout: "pipe", + stderr: "pipe", + env: env ?? process.env, + }) + const exitCode = await proc.exited + const stderr = await new Response(proc.stderr).text() + if (exitCode !== 0) { + throw new Error(`git ${args.join(" ")} failed (exit ${exitCode}).\nstderr: ${stderr}`) + } + } + +describe("CLI", () => { + test("install converts fixture plugin to OpenCode output", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-opencode-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "install", + fixtureRoot, + "--to", + "opencode", + "--output", + tempRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "agents", "repo-research-analyst.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "agents", "security-sentinel.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "plugins", "converted-hooks.ts"))).toBe(true) + }) + + test("install defaults output to ~/.opencode", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-local-default-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const repoRoot = path.join(import.meta.dir, "..") + const proc = Bun.spawn([ + "bun", + "run", + path.join(repoRoot, "src", "index.ts"), + "install", + fixtureRoot, + "--to", + "opencode", + ], { + cwd: tempRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: tempRoot, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + expect(await exists(path.join(tempRoot, ".opencode", "opencode.json"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "agents", "repo-research-analyst.md"))).toBe(true) + }) + + test("list returns plugins in a temp workspace", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-list-")) + const pluginsRoot = path.join(tempRoot, "plugins", "demo-plugin", ".claude-plugin") + await fs.mkdir(pluginsRoot, { recursive: true }) + await fs.writeFile(path.join(pluginsRoot, "plugin.json"), "{\n \"name\": \"demo-plugin\",\n \"version\": \"1.0.0\"\n}\n") + + const repoRoot = path.join(import.meta.dir, "..") + const proc = Bun.spawn(["bun", "run", path.join(repoRoot, "src", "index.ts"), "list"], { + cwd: tempRoot, + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("demo-plugin") + }) + + test("install pulls from GitHub when local path is missing", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-github-install-")) + const workspaceRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-github-workspace-")) + const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-github-repo-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + const pluginRoot = path.join(repoRoot, "plugins", "compound-engineering") + + await fs.mkdir(path.dirname(pluginRoot), { recursive: true }) + await fs.cp(fixtureRoot, pluginRoot, { recursive: true }) + + const gitEnv = { + ...process.env, + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + } + + await runGit(["init"], repoRoot, gitEnv) + await runGit(["add", "."], repoRoot, gitEnv) + await runGit(["commit", "-m", "fixture"], repoRoot, gitEnv) + + const projectRoot = path.join(import.meta.dir, "..") + const proc = Bun.spawn([ + "bun", + "run", + path.join(projectRoot, "src", "index.ts"), + "install", + "compound-engineering", + "--to", + "opencode", + ], { + cwd: workspaceRoot, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + HOME: tempRoot, + COMPOUND_PLUGIN_GITHUB_SOURCE: repoRoot, + }, + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + expect(await exists(path.join(tempRoot, ".opencode", "opencode.json"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "agents", "repo-research-analyst.md"))).toBe(true) + }) + + test("convert writes OpenCode output", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-convert-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "convert", + fixtureRoot, + "--to", + "opencode", + "--output", + tempRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Converted compound-engineering") + expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true) + }) + + test("convert supports --codex-home for codex output", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-codex-home-")) + const codexRoot = path.join(tempRoot, ".codex") + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "convert", + fixtureRoot, + "--to", + "codex", + "--codex-home", + codexRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Converted compound-engineering") + expect(stdout).toContain(codexRoot) + expect(await exists(path.join(codexRoot, "prompts", "workflows-review.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "skills", "workflows-review", "SKILL.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true) + }) + + test("install supports --also with codex output", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "cli-also-")) + const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + const codexRoot = path.join(tempRoot, ".codex") + + const proc = Bun.spawn([ + "bun", + "run", + "src/index.ts", + "install", + fixtureRoot, + "--to", + "opencode", + "--also", + "codex", + "--codex-home", + codexRoot, + "--output", + tempRoot, + ], { + cwd: path.join(import.meta.dir, ".."), + stdout: "pipe", + stderr: "pipe", + }) + + const exitCode = await proc.exited + const stdout = await new Response(proc.stdout).text() + const stderr = await new Response(proc.stderr).text() + + if (exitCode !== 0) { + throw new Error(`CLI failed (exit ${exitCode}).\nstdout: ${stdout}\nstderr: ${stderr}`) + } + + expect(stdout).toContain("Installed compound-engineering") + expect(stdout).toContain(codexRoot) + expect(await exists(path.join(codexRoot, "prompts", "workflows-review.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "skills", "workflows-review", "SKILL.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "AGENTS.md"))).toBe(true) + }) +}) diff --git a/tests/codex-agents.test.ts b/tests/codex-agents.test.ts new file mode 100644 index 0000000..252c698 --- /dev/null +++ b/tests/codex-agents.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { + CODEX_AGENTS_BLOCK_END, + CODEX_AGENTS_BLOCK_START, + ensureCodexAgentsFile, +} from "../src/utils/codex-agents" + +async function readFile(filePath: string): Promise { + return fs.readFile(filePath, "utf8") +} + +describe("ensureCodexAgentsFile", () => { + test("creates AGENTS.md with managed block when missing", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-")) + await ensureCodexAgentsFile(tempRoot) + + const agentsPath = path.join(tempRoot, "AGENTS.md") + const content = await readFile(agentsPath) + expect(content).toContain(CODEX_AGENTS_BLOCK_START) + expect(content).toContain("Tool mapping") + expect(content).toContain(CODEX_AGENTS_BLOCK_END) + }) + + test("appends block without touching existing content", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-existing-")) + const agentsPath = path.join(tempRoot, "AGENTS.md") + await fs.writeFile(agentsPath, "# My Rules\n\nKeep this.") + + await ensureCodexAgentsFile(tempRoot) + + const content = await readFile(agentsPath) + expect(content).toContain("# My Rules") + expect(content).toContain("Keep this.") + expect(content).toContain(CODEX_AGENTS_BLOCK_START) + expect(content).toContain(CODEX_AGENTS_BLOCK_END) + }) + + test("replaces only the managed block when present", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-agents-update-")) + const agentsPath = path.join(tempRoot, "AGENTS.md") + const seed = [ + "Intro text", + CODEX_AGENTS_BLOCK_START, + "old content", + CODEX_AGENTS_BLOCK_END, + "Footer text", + ].join("\n") + await fs.writeFile(agentsPath, seed) + + await ensureCodexAgentsFile(tempRoot) + + const content = await readFile(agentsPath) + expect(content).toContain("Intro text") + expect(content).toContain("Footer text") + expect(content).not.toContain("old content") + expect(content).toContain(CODEX_AGENTS_BLOCK_START) + expect(content).toContain(CODEX_AGENTS_BLOCK_END) + }) +}) diff --git a/tests/codex-converter.test.ts b/tests/codex-converter.test.ts new file mode 100644 index 0000000..ed8841a --- /dev/null +++ b/tests/codex-converter.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from "bun:test" +import { convertClaudeToCodex } from "../src/converters/claude-to-codex" +import { parseFrontmatter } from "../src/utils/frontmatter" +import type { ClaudePlugin } from "../src/types/claude" + +const fixturePlugin: ClaudePlugin = { + root: "/tmp/plugin", + manifest: { name: "fixture", version: "1.0.0" }, + agents: [ + { + name: "Security Reviewer", + description: "Security-focused agent", + capabilities: ["Threat modeling", "OWASP"], + model: "claude-sonnet-4-20250514", + body: "Focus on vulnerabilities.", + sourcePath: "/tmp/plugin/agents/security-reviewer.md", + }, + ], + commands: [ + { + name: "workflows:plan", + description: "Planning command", + argumentHint: "[FOCUS]", + model: "inherit", + allowedTools: ["Read"], + body: "Plan the work.", + sourcePath: "/tmp/plugin/commands/workflows/plan.md", + }, + ], + skills: [ + { + name: "existing-skill", + description: "Existing skill", + sourceDir: "/tmp/plugin/skills/existing-skill", + skillPath: "/tmp/plugin/skills/existing-skill/SKILL.md", + }, + ], + hooks: undefined, + mcpServers: { + local: { command: "echo", args: ["hello"] }, + }, +} + +describe("convertClaudeToCodex", () => { + test("converts commands to prompts and agents to skills", () => { + const bundle = convertClaudeToCodex(fixturePlugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + expect(bundle.prompts).toHaveLength(1) + const prompt = bundle.prompts[0] + expect(prompt.name).toBe("workflows-plan") + + const parsedPrompt = parseFrontmatter(prompt.content) + expect(parsedPrompt.data.description).toBe("Planning command") + expect(parsedPrompt.data["argument-hint"]).toBe("[FOCUS]") + expect(parsedPrompt.body).toContain("$workflows-plan") + expect(parsedPrompt.body).toContain("Plan the work.") + + expect(bundle.skillDirs[0]?.name).toBe("existing-skill") + expect(bundle.generatedSkills).toHaveLength(2) + + const commandSkill = bundle.generatedSkills.find((skill) => skill.name === "workflows-plan") + expect(commandSkill).toBeDefined() + const parsedCommandSkill = parseFrontmatter(commandSkill!.content) + expect(parsedCommandSkill.data.name).toBe("workflows-plan") + expect(parsedCommandSkill.data.description).toBe("Planning command") + expect(parsedCommandSkill.body).toContain("Allowed tools") + + const agentSkill = bundle.generatedSkills.find((skill) => skill.name === "security-reviewer") + expect(agentSkill).toBeDefined() + const parsedSkill = parseFrontmatter(agentSkill!.content) + expect(parsedSkill.data.name).toBe("security-reviewer") + expect(parsedSkill.data.description).toBe("Security-focused agent") + expect(parsedSkill.body).toContain("Capabilities") + expect(parsedSkill.body).toContain("Threat modeling") + }) + + test("passes through MCP servers", () => { + const bundle = convertClaudeToCodex(fixturePlugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + expect(bundle.mcpServers?.local?.command).toBe("echo") + expect(bundle.mcpServers?.local?.args).toEqual(["hello"]) + }) + + test("truncates generated skill descriptions to Codex limits and single line", () => { + const longDescription = `Line one\nLine two ${"a".repeat(2000)}` + const plugin: ClaudePlugin = { + ...fixturePlugin, + agents: [ + { + name: "Long Description Agent", + description: longDescription, + body: "Body", + sourcePath: "/tmp/plugin/agents/long.md", + }, + ], + commands: [], + skills: [], + } + + const bundle = convertClaudeToCodex(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const generated = bundle.generatedSkills[0] + const parsed = parseFrontmatter(generated.content) + const description = String(parsed.data.description ?? "") + expect(description.length).toBeLessThanOrEqual(1024) + expect(description).not.toContain("\n") + expect(description.endsWith("...")).toBe(true) + }) +}) diff --git a/tests/codex-writer.test.ts b/tests/codex-writer.test.ts new file mode 100644 index 0000000..ad2f03a --- /dev/null +++ b/tests/codex-writer.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeCodexBundle } from "../src/targets/codex" +import type { CodexBundle } from "../src/types/codex" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("writeCodexBundle", () => { + test("writes prompts, skills, and config", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-test-")) + const bundle: CodexBundle = { + prompts: [{ name: "command-one", content: "Prompt content" }], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + generatedSkills: [{ name: "agent-skill", content: "Skill content" }], + mcpServers: { + local: { command: "echo", args: ["hello"], env: { KEY: "VALUE" } }, + remote: { + url: "https://example.com/mcp", + headers: { Authorization: "Bearer token" }, + }, + }, + } + + await writeCodexBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, ".codex", "prompts", "command-one.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".codex", "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".codex", "skills", "agent-skill", "SKILL.md"))).toBe(true) + const configPath = path.join(tempRoot, ".codex", "config.toml") + expect(await exists(configPath)).toBe(true) + + const config = await fs.readFile(configPath, "utf8") + expect(config).toContain("[mcp_servers.local]") + expect(config).toContain("command = \"echo\"") + expect(config).toContain("args = [\"hello\"]") + expect(config).toContain("[mcp_servers.local.env]") + expect(config).toContain("KEY = \"VALUE\"") + expect(config).toContain("[mcp_servers.remote]") + expect(config).toContain("url = \"https://example.com/mcp\"") + expect(config).toContain("http_headers") + }) + + test("writes directly into a .codex output root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codex-home-")) + const codexRoot = path.join(tempRoot, ".codex") + const bundle: CodexBundle = { + prompts: [{ name: "command-one", content: "Prompt content" }], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + generatedSkills: [], + } + + await writeCodexBundle(codexRoot, bundle) + + expect(await exists(path.join(codexRoot, "prompts", "command-one.md"))).toBe(true) + expect(await exists(path.join(codexRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) + }) +}) diff --git a/tests/converter.test.ts b/tests/converter.test.ts new file mode 100644 index 0000000..8526322 --- /dev/null +++ b/tests/converter.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import { loadClaudePlugin } from "../src/parsers/claude" +import { convertClaudeToOpenCode } from "../src/converters/claude-to-opencode" +import { parseFrontmatter } from "../src/utils/frontmatter" + +const fixtureRoot = path.join(import.meta.dir, "fixtures", "sample-plugin") + +describe("convertClaudeToOpenCode", () => { + test("maps commands, permissions, and agents", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "from-commands", + }) + + expect(bundle.config.command?.["workflows:review"]).toBeDefined() + expect(bundle.config.command?.["plan_review"]).toBeDefined() + + const permission = bundle.config.permission as Record> + expect(Object.keys(permission).sort()).toEqual([ + "bash", + "edit", + "glob", + "grep", + "list", + "patch", + "question", + "read", + "skill", + "task", + "todoread", + "todowrite", + "webfetch", + "write", + ]) + expect(permission.edit).toBe("allow") + expect(permission.write).toBe("allow") + const bashPermission = permission.bash as Record + expect(bashPermission["ls *"]).toBe("allow") + expect(bashPermission["git *"]).toBe("allow") + expect(permission.webfetch).toBe("allow") + + const readPermission = permission.read as Record + expect(readPermission["*"]).toBe("deny") + expect(readPermission[".env"]).toBe("allow") + + expect(permission.question).toBe("allow") + expect(permission.todowrite).toBe("allow") + expect(permission.todoread).toBe("allow") + + const agentFile = bundle.agents.find((agent) => agent.name === "repo-research-analyst") + expect(agentFile).toBeDefined() + const parsed = parseFrontmatter(agentFile!.content) + expect(parsed.data.mode).toBe("subagent") + }) + + test("normalizes models and infers temperature", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: true, + permissions: "none", + }) + + const securityAgent = bundle.agents.find((agent) => agent.name === "security-sentinel") + expect(securityAgent).toBeDefined() + const parsed = parseFrontmatter(securityAgent!.content) + expect(parsed.data.model).toBe("anthropic/claude-sonnet-4-20250514") + expect(parsed.data.temperature).toBe(0.1) + + const modelCommand = bundle.config.command?.["workflows:work"] + expect(modelCommand?.model).toBe("openai/gpt-4o") + }) + + test("converts hooks into plugin file", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const hookFile = bundle.plugins.find((file) => file.name === "converted-hooks.ts") + expect(hookFile).toBeDefined() + expect(hookFile!.content).toContain("\"tool.execute.before\"") + expect(hookFile!.content).toContain("\"tool.execute.after\"") + expect(hookFile!.content).toContain("\"session.created\"") + expect(hookFile!.content).toContain("\"session.deleted\"") + expect(hookFile!.content).toContain("\"session.idle\"") + expect(hookFile!.content).toContain("\"experimental.session.compacting\"") + expect(hookFile!.content).toContain("\"permission.requested\"") + expect(hookFile!.content).toContain("\"permission.replied\"") + expect(hookFile!.content).toContain("\"message.created\"") + expect(hookFile!.content).toContain("\"message.updated\"") + expect(hookFile!.content).toContain("echo before") + expect(hookFile!.content).toContain("echo before two") + expect(hookFile!.content).toContain("// timeout: 30s") + expect(hookFile!.content).toContain("// Prompt hook for Write|Edit") + expect(hookFile!.content).toContain("// Agent hook for Write|Edit: security-sentinel") + }) + + test("converts MCP servers", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + + const mcp = bundle.config.mcp ?? {} + expect(mcp["local-tooling"]).toEqual({ + type: "local", + command: ["echo", "fixture"], + environment: undefined, + enabled: true, + }) + expect(mcp.context7).toEqual({ + type: "remote", + url: "https://mcp.context7.com/mcp", + headers: undefined, + enabled: true, + }) + }) + + test("permission modes set expected keys", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const noneBundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "none", + }) + expect(noneBundle.config.permission).toBeUndefined() + + const broadBundle = convertClaudeToOpenCode(plugin, { + agentMode: "subagent", + inferTemperature: false, + permissions: "broad", + }) + expect(broadBundle.config.permission).toEqual({ + read: "allow", + write: "allow", + edit: "allow", + bash: "allow", + grep: "allow", + glob: "allow", + list: "allow", + webfetch: "allow", + skill: "allow", + patch: "allow", + task: "allow", + question: "allow", + todowrite: "allow", + todoread: "allow", + }) + }) + + test("supports primary agent mode", async () => { + const plugin = await loadClaudePlugin(fixtureRoot) + const bundle = convertClaudeToOpenCode(plugin, { + agentMode: "primary", + inferTemperature: false, + permissions: "none", + }) + + const agentFile = bundle.agents.find((agent) => agent.name === "repo-research-analyst") + const parsed = parseFrontmatter(agentFile!.content) + expect(parsed.data.mode).toBe("primary") + }) +}) diff --git a/tests/fixtures/custom-paths/.claude-plugin/plugin.json b/tests/fixtures/custom-paths/.claude-plugin/plugin.json new file mode 100644 index 0000000..3d1a3d2 --- /dev/null +++ b/tests/fixtures/custom-paths/.claude-plugin/plugin.json @@ -0,0 +1,8 @@ +{ + "name": "custom-paths", + "version": "1.0.0", + "agents": "./custom-agents", + "commands": ["./custom-commands"], + "skills": "./custom-skills", + "hooks": "./custom-hooks/hooks.json" +} diff --git a/tests/fixtures/custom-paths/agents/default-agent.md b/tests/fixtures/custom-paths/agents/default-agent.md new file mode 100644 index 0000000..fb34045 --- /dev/null +++ b/tests/fixtures/custom-paths/agents/default-agent.md @@ -0,0 +1,5 @@ +--- +name: default-agent +--- + +Default agent diff --git a/tests/fixtures/custom-paths/commands/default-command.md b/tests/fixtures/custom-paths/commands/default-command.md new file mode 100644 index 0000000..18b101e --- /dev/null +++ b/tests/fixtures/custom-paths/commands/default-command.md @@ -0,0 +1,5 @@ +--- +name: default-command +--- + +Default command diff --git a/tests/fixtures/custom-paths/custom-agents/custom-agent.md b/tests/fixtures/custom-paths/custom-agents/custom-agent.md new file mode 100644 index 0000000..5997b5f --- /dev/null +++ b/tests/fixtures/custom-paths/custom-agents/custom-agent.md @@ -0,0 +1,5 @@ +--- +name: custom-agent +--- + +Custom agent diff --git a/tests/fixtures/custom-paths/custom-commands/custom-command.md b/tests/fixtures/custom-paths/custom-commands/custom-command.md new file mode 100644 index 0000000..6df044d --- /dev/null +++ b/tests/fixtures/custom-paths/custom-commands/custom-command.md @@ -0,0 +1,5 @@ +--- +name: custom-command +--- + +Custom command diff --git a/tests/fixtures/custom-paths/custom-hooks/hooks.json b/tests/fixtures/custom-paths/custom-hooks/hooks.json new file mode 100644 index 0000000..3cf9e23 --- /dev/null +++ b/tests/fixtures/custom-paths/custom-hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "PostToolUse": [ + { "matcher": "Write", "hooks": [{ "type": "command", "command": "echo custom" }] } + ] + } +} diff --git a/tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md b/tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md new file mode 100644 index 0000000..bec00b0 --- /dev/null +++ b/tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: custom-skill +--- + +Custom skill diff --git a/tests/fixtures/custom-paths/hooks/hooks.json b/tests/fixtures/custom-paths/hooks/hooks.json new file mode 100644 index 0000000..9b3ac25 --- /dev/null +++ b/tests/fixtures/custom-paths/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "hooks": { + "PreToolUse": [ + { "matcher": "Read", "hooks": [{ "type": "command", "command": "echo default" }] } + ] + } +} diff --git a/tests/fixtures/custom-paths/skills/default-skill/SKILL.md b/tests/fixtures/custom-paths/skills/default-skill/SKILL.md new file mode 100644 index 0000000..cb208bd --- /dev/null +++ b/tests/fixtures/custom-paths/skills/default-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: default-skill +--- + +Default skill diff --git a/tests/fixtures/invalid-command-path/.claude-plugin/plugin.json b/tests/fixtures/invalid-command-path/.claude-plugin/plugin.json new file mode 100644 index 0000000..4d68331 --- /dev/null +++ b/tests/fixtures/invalid-command-path/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "invalid-command-path", + "version": "1.0.0", + "commands": ["../outside-commands"] +} diff --git a/tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json b/tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json new file mode 100644 index 0000000..b170363 --- /dev/null +++ b/tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "invalid-hooks-path", + "version": "1.0.0", + "hooks": ["../outside-hooks.json"] +} diff --git a/tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json b/tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json new file mode 100644 index 0000000..98216bf --- /dev/null +++ b/tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "invalid-mcp-path", + "version": "1.0.0", + "mcpServers": ["../outside-mcp.json"] +} diff --git a/tests/fixtures/mcp-file/.claude-plugin/plugin.json b/tests/fixtures/mcp-file/.claude-plugin/plugin.json new file mode 100644 index 0000000..52ec99e --- /dev/null +++ b/tests/fixtures/mcp-file/.claude-plugin/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "mcp-file", + "version": "1.0.0", + "description": "MCP file fixture" +} diff --git a/tests/fixtures/mcp-file/.mcp.json b/tests/fixtures/mcp-file/.mcp.json new file mode 100644 index 0000000..ae6dc7f --- /dev/null +++ b/tests/fixtures/mcp-file/.mcp.json @@ -0,0 +1,6 @@ +{ + "remote": { + "type": "sse", + "url": "https://example.com/stream" + } +} diff --git a/tests/fixtures/sample-plugin/.claude-plugin/plugin.json b/tests/fixtures/sample-plugin/.claude-plugin/plugin.json new file mode 100644 index 0000000..a52d28b --- /dev/null +++ b/tests/fixtures/sample-plugin/.claude-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "compound-engineering", + "version": "2.27.0", + "description": "Fixture aligned with the Compound Engineering plugin", + "author": { + "name": "Kieran Klaassen", + "email": "kieran@every.to", + "url": "https://github.com/kieranklaassen" + }, + "homepage": "https://every.to/source-code/my-ai-had-already-fixed-the-code-before-i-saw-it", + "repository": "https://github.com/EveryInc/compound-engineering-plugin", + "license": "MIT", + "keywords": [ + "compound-engineering", + "workflow-automation", + "code-review", + "agents" + ], + "mcpServers": { + "context7": { + "type": "http", + "url": "https://mcp.context7.com/mcp" + }, + "local-tooling": { + "type": "stdio", + "command": "echo", + "args": ["fixture"] + } + } +} diff --git a/tests/fixtures/sample-plugin/agents/agent-one.md b/tests/fixtures/sample-plugin/agents/agent-one.md new file mode 100644 index 0000000..316fb20 --- /dev/null +++ b/tests/fixtures/sample-plugin/agents/agent-one.md @@ -0,0 +1,10 @@ +--- +name: repo-research-analyst +description: Research repository structure and conventions +capabilities: + - "Capability A" + - "Capability B" +model: inherit +--- + +Repo research analyst body. diff --git a/tests/fixtures/sample-plugin/agents/security-reviewer.md b/tests/fixtures/sample-plugin/agents/security-reviewer.md new file mode 100644 index 0000000..d6edbbc --- /dev/null +++ b/tests/fixtures/sample-plugin/agents/security-reviewer.md @@ -0,0 +1,7 @@ +--- +name: security-sentinel +description: Security audits and vulnerability assessments +model: claude-sonnet-4-20250514 +--- + +Security sentinel body. diff --git a/tests/fixtures/sample-plugin/commands/command-one.md b/tests/fixtures/sample-plugin/commands/command-one.md new file mode 100644 index 0000000..0f97006 --- /dev/null +++ b/tests/fixtures/sample-plugin/commands/command-one.md @@ -0,0 +1,7 @@ +--- +name: workflows:review +description: Run a multi-agent review workflow +allowed-tools: Read, Write, Edit, Bash(ls:*), Bash(git:*), Grep, Glob, List, Patch, Task +--- + +Workflows review body. diff --git a/tests/fixtures/sample-plugin/commands/model-command.md b/tests/fixtures/sample-plugin/commands/model-command.md new file mode 100644 index 0000000..6631a24 --- /dev/null +++ b/tests/fixtures/sample-plugin/commands/model-command.md @@ -0,0 +1,8 @@ +--- +name: workflows:work +description: Execute planned tasks step by step +model: gpt-4o +allowed-tools: WebFetch +--- + +Workflows work body. diff --git a/tests/fixtures/sample-plugin/commands/nested/command-two.md b/tests/fixtures/sample-plugin/commands/nested/command-two.md new file mode 100644 index 0000000..1fba7dd --- /dev/null +++ b/tests/fixtures/sample-plugin/commands/nested/command-two.md @@ -0,0 +1,9 @@ +--- +name: plan_review +description: Review a plan with multiple agents +allowed-tools: + - Read + - Edit +--- + +Plan review body. diff --git a/tests/fixtures/sample-plugin/commands/pattern-command.md b/tests/fixtures/sample-plugin/commands/pattern-command.md new file mode 100644 index 0000000..baf3eb7 --- /dev/null +++ b/tests/fixtures/sample-plugin/commands/pattern-command.md @@ -0,0 +1,7 @@ +--- +name: report-bug +description: Report a bug with structured context +allowed-tools: Read(.env), Bash(git:*) +--- + +Report bug body. diff --git a/tests/fixtures/sample-plugin/commands/skill-command.md b/tests/fixtures/sample-plugin/commands/skill-command.md new file mode 100644 index 0000000..c2db84d --- /dev/null +++ b/tests/fixtures/sample-plugin/commands/skill-command.md @@ -0,0 +1,7 @@ +--- +name: create-agent-skill +description: Create or edit a Claude Code skill +allowed-tools: Skill(create-agent-skills) +--- + +Create agent skill body. diff --git a/tests/fixtures/sample-plugin/commands/todo-command.md b/tests/fixtures/sample-plugin/commands/todo-command.md new file mode 100644 index 0000000..29422d8 --- /dev/null +++ b/tests/fixtures/sample-plugin/commands/todo-command.md @@ -0,0 +1,7 @@ +--- +name: workflows:plan +description: Create a structured plan from requirements +allowed-tools: Question, TodoWrite, TodoRead +--- + +Workflows plan body. diff --git a/tests/fixtures/sample-plugin/hooks/hooks.json b/tests/fixtures/sample-plugin/hooks/hooks.json new file mode 100644 index 0000000..dd09e50 --- /dev/null +++ b/tests/fixtures/sample-plugin/hooks/hooks.json @@ -0,0 +1,156 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo before", + "timeout": 30 + }, + { + "type": "command", + "command": "echo before two" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Write|Edit", + "hooks": [ + { + "type": "prompt", + "prompt": "After write" + }, + { + "type": "agent", + "agent": "security-sentinel" + } + ] + } + ], + "PostToolUseFailure": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo failed" + } + ] + } + ], + "PermissionRequest": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "echo permission" + } + ] + } + ], + "UserPromptSubmit": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo prompt" + } + ] + } + ], + "Notification": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo notify" + } + ] + } + ], + "SessionStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo session start" + } + ] + } + ], + "SessionEnd": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo session end" + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo stop" + } + ] + } + ], + "PreCompact": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo compact" + } + ] + } + ], + "Setup": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo setup" + } + ] + } + ], + "SubagentStart": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo subagent start" + } + ] + } + ], + "SubagentStop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "echo subagent stop" + } + ] + } + ] + } +} diff --git a/tests/fixtures/sample-plugin/skills/skill-one/SKILL.md b/tests/fixtures/sample-plugin/skills/skill-one/SKILL.md new file mode 100644 index 0000000..523e25a --- /dev/null +++ b/tests/fixtures/sample-plugin/skills/skill-one/SKILL.md @@ -0,0 +1,6 @@ +--- +name: skill-one +description: Sample skill +--- + +Skill body. diff --git a/tests/frontmatter.test.ts b/tests/frontmatter.test.ts new file mode 100644 index 0000000..33b9f0d --- /dev/null +++ b/tests/frontmatter.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, test } from "bun:test" +import { formatFrontmatter, parseFrontmatter } from "../src/utils/frontmatter" + +describe("frontmatter", () => { + test("parseFrontmatter returns body when no frontmatter", () => { + const raw = "Hello\nWorld" + const result = parseFrontmatter(raw) + expect(result.data).toEqual({}) + expect(result.body).toBe(raw) + }) + + test("formatFrontmatter round trips", () => { + const body = "Body text" + const formatted = formatFrontmatter({ name: "agent", description: "Test" }, body) + const parsed = parseFrontmatter(formatted) + expect(parsed.data.name).toBe("agent") + expect(parsed.data.description).toBe("Test") + expect(parsed.body.trim()).toBe(body) + }) +}) diff --git a/tests/opencode-writer.test.ts b/tests/opencode-writer.test.ts new file mode 100644 index 0000000..01fc765 --- /dev/null +++ b/tests/opencode-writer.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test" +import { promises as fs } from "fs" +import path from "path" +import os from "os" +import { writeOpenCodeBundle } from "../src/targets/opencode" +import type { OpenCodeBundle } from "../src/types/opencode" + +async function exists(filePath: string): Promise { + try { + await fs.access(filePath) + return true + } catch { + return false + } +} + +describe("writeOpenCodeBundle", () => { + test("writes config, agents, plugins, and skills", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-test-")) + const bundle: OpenCodeBundle = { + config: { $schema: "https://opencode.ai/config.json" }, + agents: [{ name: "agent-one", content: "Agent content" }], + plugins: [{ name: "hook.ts", content: "export {}" }], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + } + + await writeOpenCodeBundle(tempRoot, bundle) + + expect(await exists(path.join(tempRoot, "opencode.json"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "agents", "agent-one.md"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "plugins", "hook.ts"))).toBe(true) + expect(await exists(path.join(tempRoot, ".opencode", "skills", "skill-one", "SKILL.md"))).toBe(true) + }) + + test("writes directly into a .opencode output root", async () => { + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-root-")) + const outputRoot = path.join(tempRoot, ".opencode") + const bundle: OpenCodeBundle = { + config: { $schema: "https://opencode.ai/config.json" }, + agents: [{ name: "agent-one", content: "Agent content" }], + plugins: [], + skillDirs: [ + { + name: "skill-one", + sourceDir: path.join(import.meta.dir, "fixtures", "sample-plugin", "skills", "skill-one"), + }, + ], + } + + await writeOpenCodeBundle(outputRoot, bundle) + + expect(await exists(path.join(outputRoot, "opencode.json"))).toBe(true) + expect(await exists(path.join(outputRoot, "agents", "agent-one.md"))).toBe(true) + expect(await exists(path.join(outputRoot, "skills", "skill-one", "SKILL.md"))).toBe(true) + expect(await exists(path.join(outputRoot, ".opencode"))).toBe(false) + }) +}) diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..70abc4d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "resolveJsonModule": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "types": ["bun-types"] + }, + "include": ["src/**/*.ts"] +}