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
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
node_modules/
|
||||
.codex/
|
||||
|
||||
48
AGENTS.md
Normal file
48
AGENTS.md
Normal file
@@ -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 <provider>` 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.
|
||||
22
README.md
22
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
|
||||
|
||||
```
|
||||
|
||||
30
bun.lock
Normal file
30
bun.lock
Normal file
@@ -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=="],
|
||||
}
|
||||
}
|
||||
67
docs/specs/claude-code.md
Normal file
67
docs/specs/claude-code.md
Normal file
@@ -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. citeturn2view7
|
||||
- The `.claude-plugin/` directory only holds the manifest; component directories (commands/agents/skills/hooks) must be at the plugin root, not inside `.claude-plugin/`. citeturn2view7
|
||||
- The reference table lists default locations and notes that `commands/` is the legacy home for skills; new skills should live under `skills/<name>/SKILL.md`. citeturn2view7
|
||||
|
||||
## Manifest schema (`.claude-plugin/plugin.json`)
|
||||
|
||||
- `name` is required and must be kebab-case with no spaces. citeturn2view8
|
||||
- Metadata fields include `version`, `description`, `author`, `homepage`, `repository`, `license`, and `keywords`. citeturn2view8
|
||||
- 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. citeturn2view8turn2view9
|
||||
- Custom paths supplement defaults; they do not replace them, and all paths must be relative to the plugin root and start with `./`. citeturn2view9
|
||||
|
||||
## 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. citeturn6search0
|
||||
|
||||
## Skills (`skills/<name>/SKILL.md`)
|
||||
|
||||
- Skills are directories containing `SKILL.md` (plus optional support files). Skills and commands are auto-discovered when the plugin is installed. citeturn2view7
|
||||
- Skills can be invoked with `/<skill-name>` and are stored in `~/.claude/skills` or `.claude/skills` (project-level); plugins can also ship skills. citeturn12view0
|
||||
- Skill frontmatter examples include `name`, `description`, and optional `allowed-tools`. citeturn12view0
|
||||
|
||||
## Agents (`agents/*.md`)
|
||||
|
||||
- Agents are markdown files with frontmatter such as `description` and `capabilities`, plus descriptive content for when to invoke the agent. citeturn2view7
|
||||
|
||||
## 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. citeturn2view7
|
||||
- Plugin hooks are merged with user and project hooks when the plugin is enabled, and matching hooks run in parallel. citeturn1search0
|
||||
- Supported events include `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `PermissionRequest`, `UserPromptSubmit`, `Notification`, `Stop`, `SubagentStart`, `SubagentStop`, `Setup`, `SessionStart`, `SessionEnd`, and `PreCompact`. citeturn2view7
|
||||
- Hook types include `command`, `prompt`, and `agent`. citeturn2view7
|
||||
- Hooks can use `${CLAUDE_PLUGIN_ROOT}` to reference plugin files. citeturn1search0
|
||||
|
||||
## MCP servers
|
||||
|
||||
- Plugins can define MCP servers in `.mcp.json` or inline under `mcpServers` in the manifest. Configuration includes `command`, `args`, `env`, and `cwd`. citeturn2view7turn2view10
|
||||
- Plugin MCP servers start automatically when enabled and appear as standard MCP tools. citeturn2view10
|
||||
|
||||
## 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. citeturn2view7turn2view10
|
||||
|
||||
## 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`). citeturn2view12
|
||||
- To access external files, use symlinks inside the plugin directory or restructure your marketplace so the plugin root contains shared files. citeturn2view12
|
||||
|
||||
## Marketplace schema (`.claude-plugin/marketplace.json`)
|
||||
|
||||
- A marketplace JSON file lists plugins and includes fields for marketplace metadata and a `plugins` array. citeturn8view2
|
||||
- Each plugin entry includes at least a `name` and `source` and can include additional manifest fields. citeturn8view2
|
||||
59
docs/specs/codex.md
Normal file
59
docs/specs/codex.md
Normal file
@@ -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. citeturn2view0
|
||||
- Configuration precedence is: CLI flags → profile values → root-level values in `config.toml` → built-in defaults. citeturn2view0
|
||||
- Codex stores local state under `CODEX_HOME` (defaults to `~/.codex`) and includes `config.toml` there. citeturn4view0
|
||||
|
||||
## Profiles and providers
|
||||
|
||||
- Profiles are defined under `[profiles.<name>]` and selected with `codex --profile <name>`. citeturn4view0
|
||||
- A top-level `profile = "<name>"` sets the default profile; CLI flags can override it. citeturn4view0
|
||||
- Profiles are experimental and not supported in the IDE extension. citeturn4view0
|
||||
- Custom model providers can be defined with base URL, wire API, and optional headers, then referenced via `model_provider`. citeturn4view0
|
||||
|
||||
## Custom prompts (slash commands)
|
||||
|
||||
- Custom prompts are Markdown files stored under `~/.codex/prompts/`. citeturn3view0
|
||||
- Custom prompts require explicit invocation and aren’t shared through the repository; use skills to share or auto-invoke. citeturn3view0
|
||||
- Prompts are invoked as `/prompts:<name>` in the slash command UI. citeturn3view0
|
||||
- Prompt front matter supports `description:` and `argument-hint:`. citeturn3view0turn2view3
|
||||
- Prompt arguments support `$1`–`$9`, `$ARGUMENTS`, and named placeholders like `$FILE` provided as `KEY=value`. citeturn2view3
|
||||
- Codex ignores non-Markdown files in the prompts directory. citeturn2view3
|
||||
|
||||
## AGENTS.md instructions
|
||||
|
||||
- Codex reads `AGENTS.md` files before doing any work and builds a combined instruction chain. citeturn3view1
|
||||
- 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. citeturn3view1
|
||||
- Codex concatenates files from root down; files closer to the working directory appear later and override earlier guidance. citeturn3view1
|
||||
|
||||
## Skills (Agent Skills)
|
||||
|
||||
- A skill is a folder containing `SKILL.md` plus optional `scripts/`, `references/`, and `assets/`. citeturn3view3turn3view4
|
||||
- `SKILL.md` uses YAML front matter and requires `name` and `description`. citeturn3view3turn3view4
|
||||
- Required fields are single-line with length limits (name ≤ 100 chars, description ≤ 500 chars). citeturn3view4
|
||||
- At startup, Codex loads only each skill’s name/description; full content is injected when invoked. citeturn3view3turn3view4
|
||||
- Skills can be repo-scoped in `.codex/skills/` or user-scoped in `~/.codex/skills/`. citeturn3view4
|
||||
- Skills can be invoked explicitly using `/skills` or `$skill-name`. citeturn3view3
|
||||
|
||||
## MCP (Model Context Protocol)
|
||||
|
||||
- MCP configuration lives in `~/.codex/config.toml` and is shared by the CLI and IDE extension. citeturn3view2turn3view5
|
||||
- Each server is configured under `[mcp_servers.<server-name>]`. citeturn3view5
|
||||
- STDIO servers support `command` (required), `args`, `env`, `env_vars`, and `cwd`. citeturn3view5
|
||||
- Streamable HTTP servers support `url` (required), `bearer_token_env_var`, `http_headers`, and `env_http_headers`. citeturn3view5
|
||||
57
docs/specs/opencode.md
Normal file
57
docs/specs/opencode.md
Normal file
@@ -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. citeturn10view0
|
||||
- Config sources are merged (not replaced), with a defined precedence order from remote → global → custom → project → `.opencode` directories → inline overrides. citeturn10view0
|
||||
- Global config is stored at `~/.config/opencode/opencode.json`, and project config is `opencode.json` in the project root. citeturn10view0
|
||||
- Custom config file and directory can be provided via `OPENCODE_CONFIG` and `OPENCODE_CONFIG_DIR`. citeturn10view0
|
||||
- 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. citeturn10view0
|
||||
|
||||
## Core config keys
|
||||
|
||||
- `model` and `small_model` set the primary and lightweight models; `provider` configures provider options. citeturn10view0
|
||||
- `tools` is still supported but deprecated; permissions are now the canonical control surface. citeturn1search0
|
||||
- `permission` controls tool approvals and can be configured globally or per tool, including pattern-based rules. citeturn1search0
|
||||
- `mcp`, `instructions`, and `disabled_providers` are supported config sections. citeturn1search5
|
||||
- `plugin` can list npm packages to load at startup. citeturn1search2
|
||||
|
||||
## Tools
|
||||
|
||||
- OpenCode ships with built-in tools, and permissions determine whether each tool runs automatically, requires approval, or is denied. citeturn1search3turn1search0
|
||||
- Tools are enabled by default; permissions provide the gating mechanism. citeturn1search3
|
||||
|
||||
## Permissions
|
||||
|
||||
- Permissions resolve to `allow`, `ask`, or `deny` and can be configured globally or per tool, with pattern-based rules. citeturn1search0
|
||||
- Defaults are permissive, with special cases such as `.env` file reads. citeturn1search0
|
||||
- Agent-level permissions override the global permission block. citeturn1search1turn1search0
|
||||
|
||||
## Agents
|
||||
|
||||
- Agents can be configured in `opencode.json` or as markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. citeturn1search1turn10view0
|
||||
- Agent config supports `mode`, `model`, `temperature`, `tools`, and `permission`, and agent configs override global settings. citeturn1search1
|
||||
- Model IDs use the `provider/model-id` format. citeturn1search1
|
||||
|
||||
## 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`. citeturn1search2
|
||||
- Plugins are loaded in a defined order across config and plugin directories. citeturn1search2
|
||||
- Plugins export a function that returns a map of event handlers; the plugins doc lists supported event categories. citeturn1search2
|
||||
|
||||
## 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. citeturn10view0turn1search2
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
156
src/commands/convert.ts
Normal file
156
src/commands/convert.ts
Normal file
@@ -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()
|
||||
}
|
||||
221
src/commands/install.ts
Normal file
221
src/commands/install.ts
Normal file
@@ -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<void>
|
||||
}
|
||||
|
||||
async function resolvePluginPath(input: string): Promise<ResolvedPluginPath> {
|
||||
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<ResolvedPluginPath> {
|
||||
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<void> {
|
||||
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()}`)
|
||||
}
|
||||
}
|
||||
37
src/commands/list.ts
Normal file
37
src/commands/list.ts
Normal file
@@ -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"))
|
||||
},
|
||||
})
|
||||
124
src/converters/claude-to-codex.ts
Normal file
124
src/converters/claude-to-codex.ts
Normal file
@@ -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<string>()
|
||||
const skillDirs = plugin.skills.map((skill) => ({
|
||||
name: skill.name,
|
||||
sourceDir: skill.sourceDir,
|
||||
}))
|
||||
|
||||
const usedSkillNames = new Set<string>(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<string>): CodexGeneratedSkill {
|
||||
const name = uniqueName(normalizeName(agent.name), usedNames)
|
||||
const description = sanitizeDescription(
|
||||
agent.description ?? `Converted from Claude agent ${agent.name}`,
|
||||
)
|
||||
const frontmatter: Record<string, unknown> = { 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<string>): CodexGeneratedSkill {
|
||||
const name = uniqueName(normalizeName(command.name), usedNames)
|
||||
const frontmatter: Record<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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>): 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
|
||||
}
|
||||
392
src/converters/claude-to-opencode.ts
Normal file
392
src/converters/claude-to-opencode.ts
Normal file
@@ -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<string, string> = {
|
||||
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<string, HookEventMapping> = {
|
||||
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<string, unknown> = {
|
||||
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<string, OpenCodeCommandConfig> {
|
||||
const result: Record<string, OpenCodeCommandConfig> = {}
|
||||
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<string, ClaudeMcpServer>): Record<string, OpenCodeMcpServer> {
|
||||
const result: Record<string, OpenCodeMcpServer> = {}
|
||||
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<string>()
|
||||
const patterns: Record<string, Set<string>> = {}
|
||||
|
||||
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<string, "allow" | "deny"> = {}
|
||||
const tools: Record<string, boolean> = {}
|
||||
|
||||
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<string, "allow" | "deny"> = { "*": "deny" }
|
||||
for (const pattern of toolPatterns) {
|
||||
patternPermission[pattern] = "allow"
|
||||
}
|
||||
;(permission as Record<string, typeof patternPermission>)[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<string, "allow" | "deny"> = { "*": "deny" }
|
||||
for (const pattern of toolPatterns) {
|
||||
patternPermission[pattern] = "allow"
|
||||
}
|
||||
;(permission as Record<string, typeof patternPermission>)[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<string>()
|
||||
for (const pattern of patterns.write ?? []) combined.add(pattern)
|
||||
for (const pattern of patterns.edit ?? []) combined.add(pattern)
|
||||
const combinedPermission: Record<string, "allow" | "deny"> = { "*": "deny" }
|
||||
for (const pattern of combined) {
|
||||
combinedPermission[pattern] = "allow"
|
||||
}
|
||||
;(permission as Record<string, typeof combinedPermission>).edit = combinedPermission
|
||||
;(permission as Record<string, typeof combinedPermission>).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
|
||||
}
|
||||
20
src/index.ts
Normal file
20
src/index.ts
Normal file
@@ -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)
|
||||
248
src/parsers/claude.ts
Normal file
248
src/parsers/claude.ts
Normal file
@@ -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<ClaudePlugin> {
|
||||
const root = await resolveClaudeRoot(inputPath)
|
||||
const manifestPath = path.join(root, PLUGIN_MANIFEST)
|
||||
const manifest = await readJson<ClaudeManifest>(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<string> {
|
||||
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<ClaudeAgent[]> {
|
||||
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<ClaudeCommand[]> {
|
||||
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<ClaudeSkill[]> {
|
||||
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<ClaudeHooks | undefined> {
|
||||
const hookConfigs: ClaudeHooks[] = []
|
||||
|
||||
const defaultPath = path.join(root, "hooks", "hooks.json")
|
||||
if (await pathExists(defaultPath)) {
|
||||
hookConfigs.push(await readJson<ClaudeHooks>(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<ClaudeHooks>(resolved))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hookConfigs.push(hooksField)
|
||||
}
|
||||
}
|
||||
|
||||
if (hookConfigs.length === 0) return undefined
|
||||
return mergeHooks(hookConfigs)
|
||||
}
|
||||
|
||||
async function loadMcpServers(
|
||||
root: string,
|
||||
manifest: ClaudeManifest,
|
||||
): Promise<Record<string, ClaudeMcpServer> | undefined> {
|
||||
const field = manifest.mcpServers
|
||||
if (field) {
|
||||
if (typeof field === "string" || Array.isArray(field)) {
|
||||
return mergeMcpConfigs(await loadMcpPaths(root, field))
|
||||
}
|
||||
return field as Record<string, ClaudeMcpServer>
|
||||
}
|
||||
|
||||
const mcpPath = path.join(root, ".mcp.json")
|
||||
if (await pathExists(mcpPath)) {
|
||||
return readJson<Record<string, ClaudeMcpServer>>(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<string[]> {
|
||||
const entries = await collectFiles(dirs)
|
||||
return entries.filter((file) => file.endsWith(".md"))
|
||||
}
|
||||
|
||||
async function collectFiles(dirs: string[]): Promise<string[]> {
|
||||
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<Record<string, ClaudeMcpServer>[]> {
|
||||
const configs: Record<string, ClaudeMcpServer>[] = []
|
||||
for (const entry of toPathList(value)) {
|
||||
const resolved = resolveWithinRoot(root, entry, "mcpServers path")
|
||||
if (await pathExists(resolved)) {
|
||||
configs.push(await readJson<Record<string, ClaudeMcpServer>>(resolved))
|
||||
}
|
||||
}
|
||||
return configs
|
||||
}
|
||||
|
||||
function mergeMcpConfigs(configs: Record<string, ClaudeMcpServer>[]): Record<string, ClaudeMcpServer> {
|
||||
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.`)
|
||||
}
|
||||
91
src/targets/codex.ts
Normal file
91
src/targets/codex.ts
Normal file
@@ -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<void> {
|
||||
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, ClaudeMcpServer>): 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, string>): string {
|
||||
const parts = Object.entries(entries).map(
|
||||
([key, value]) => `${formatTomlKey(key)} = ${formatTomlString(value)}`,
|
||||
)
|
||||
return `{ ${parts.join(", ")} }`
|
||||
}
|
||||
29
src/targets/index.ts
Normal file
29
src/targets/index.ts
Normal file
@@ -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<TBundle = unknown> = {
|
||||
name: string
|
||||
implemented: boolean
|
||||
convert: (plugin: ClaudePlugin, options: ClaudeToOpenCodeOptions) => TBundle | null
|
||||
write: (outputRoot: string, bundle: TBundle) => Promise<void>
|
||||
}
|
||||
|
||||
export const targets: Record<string, TargetHandler> = {
|
||||
opencode: {
|
||||
name: "opencode",
|
||||
implemented: true,
|
||||
convert: convertClaudeToOpenCode,
|
||||
write: writeOpenCodeBundle,
|
||||
},
|
||||
codex: {
|
||||
name: "codex",
|
||||
implemented: true,
|
||||
convert: convertClaudeToCodex as TargetHandler<CodexBundle>["convert"],
|
||||
write: writeCodexBundle as TargetHandler<CodexBundle>["write"],
|
||||
},
|
||||
}
|
||||
48
src/targets/opencode.ts
Normal file
48
src/targets/opencode.ts
Normal file
@@ -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<void> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
88
src/types/claude.ts
Normal file
88
src/types/claude.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
export type ClaudeMcpServer = {
|
||||
type?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
url?: string
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
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, ClaudeMcpServer> | 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<string, ClaudeMcpServer>
|
||||
}
|
||||
|
||||
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<string, ClaudeHookMatcher[]>
|
||||
}
|
||||
23
src/types/codex.ts
Normal file
23
src/types/codex.ts
Normal file
@@ -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<string, ClaudeMcpServer>
|
||||
}
|
||||
54
src/types/opencode.ts
Normal file
54
src/types/opencode.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type OpenCodePermission = "allow" | "ask" | "deny"
|
||||
|
||||
export type OpenCodeConfig = {
|
||||
$schema?: string
|
||||
model?: string
|
||||
default_agent?: string
|
||||
tools?: Record<string, boolean>
|
||||
permission?: Record<string, OpenCodePermission | Record<string, OpenCodePermission>>
|
||||
agent?: Record<string, OpenCodeAgentConfig>
|
||||
command?: Record<string, OpenCodeCommandConfig>
|
||||
mcp?: Record<string, OpenCodeMcpServer>
|
||||
}
|
||||
|
||||
export type OpenCodeAgentConfig = {
|
||||
description?: string
|
||||
mode?: "primary" | "subagent"
|
||||
model?: string
|
||||
temperature?: number
|
||||
tools?: Record<string, boolean>
|
||||
permission?: Record<string, OpenCodePermission>
|
||||
}
|
||||
|
||||
export type OpenCodeCommandConfig = {
|
||||
description?: string
|
||||
model?: string
|
||||
agent?: string
|
||||
template: string
|
||||
}
|
||||
|
||||
export type OpenCodeMcpServer = {
|
||||
type: "local" | "remote"
|
||||
command?: string[]
|
||||
url?: string
|
||||
environment?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
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 }[]
|
||||
}
|
||||
64
src/utils/codex-agents.ts
Normal file
64
src/utils/codex-agents.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import path from "path"
|
||||
import { ensureDir, pathExists, readText, writeText } from "./files"
|
||||
|
||||
export const CODEX_AGENTS_BLOCK_START = "<!-- BEGIN COMPOUND CODEX TOOL MAP -->"
|
||||
export const CODEX_AGENTS_BLOCK_END = "<!-- END COMPOUND CODEX TOOL MAP -->"
|
||||
|
||||
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<void> {
|
||||
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"
|
||||
}
|
||||
64
src/utils/files.ts
Normal file
64
src/utils/files.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { promises as fs } from "fs"
|
||||
import path from "path"
|
||||
|
||||
export async function pathExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function ensureDir(dirPath: string): Promise<void> {
|
||||
await fs.mkdir(dirPath, { recursive: true })
|
||||
}
|
||||
|
||||
export async function readText(filePath: string): Promise<string> {
|
||||
return fs.readFile(filePath, "utf8")
|
||||
}
|
||||
|
||||
export async function readJson<T>(filePath: string): Promise<T> {
|
||||
const raw = await readText(filePath)
|
||||
return JSON.parse(raw) as T
|
||||
}
|
||||
|
||||
export async function writeText(filePath: string, content: string): Promise<void> {
|
||||
await ensureDir(path.dirname(filePath))
|
||||
await fs.writeFile(filePath, content, "utf8")
|
||||
}
|
||||
|
||||
export async function writeJson(filePath: string, data: unknown): Promise<void> {
|
||||
const content = JSON.stringify(data, null, 2)
|
||||
await writeText(filePath, content + "\n")
|
||||
}
|
||||
|
||||
export async function walkFiles(root: string): Promise<string[]> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/utils/frontmatter.ts
Normal file
65
src/utils/frontmatter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { load } from "js-yaml"
|
||||
|
||||
export type FrontmatterResult = {
|
||||
data: Record<string, unknown>
|
||||
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<string, unknown>) : {}
|
||||
return { data, body }
|
||||
}
|
||||
|
||||
export function formatFrontmatter(data: Record<string, unknown>, 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
|
||||
}
|
||||
89
tests/claude-parser.test.ts
Normal file
89
tests/claude-parser.test.ts
Normal file
@@ -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.",
|
||||
)
|
||||
})
|
||||
})
|
||||
289
tests/cli.test.ts
Normal file
289
tests/cli.test.ts
Normal file
@@ -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<boolean> {
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function runGit(args: string[], cwd: string, env?: NodeJS.ProcessEnv): Promise<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
62
tests/codex-agents.test.ts
Normal file
62
tests/codex-agents.test.ts
Normal file
@@ -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<string> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
121
tests/codex-converter.test.ts
Normal file
121
tests/codex-converter.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
76
tests/codex-writer.test.ts
Normal file
76
tests/codex-writer.test.ts
Normal file
@@ -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<boolean> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
171
tests/converter.test.ts
Normal file
171
tests/converter.test.ts
Normal file
@@ -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<string, string | Record<string, string>>
|
||||
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<string, string>
|
||||
expect(bashPermission["ls *"]).toBe("allow")
|
||||
expect(bashPermission["git *"]).toBe("allow")
|
||||
expect(permission.webfetch).toBe("allow")
|
||||
|
||||
const readPermission = permission.read as Record<string, string>
|
||||
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")
|
||||
})
|
||||
})
|
||||
8
tests/fixtures/custom-paths/.claude-plugin/plugin.json
vendored
Normal file
8
tests/fixtures/custom-paths/.claude-plugin/plugin.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
5
tests/fixtures/custom-paths/agents/default-agent.md
vendored
Normal file
5
tests/fixtures/custom-paths/agents/default-agent.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: default-agent
|
||||
---
|
||||
|
||||
Default agent
|
||||
5
tests/fixtures/custom-paths/commands/default-command.md
vendored
Normal file
5
tests/fixtures/custom-paths/commands/default-command.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: default-command
|
||||
---
|
||||
|
||||
Default command
|
||||
5
tests/fixtures/custom-paths/custom-agents/custom-agent.md
vendored
Normal file
5
tests/fixtures/custom-paths/custom-agents/custom-agent.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: custom-agent
|
||||
---
|
||||
|
||||
Custom agent
|
||||
5
tests/fixtures/custom-paths/custom-commands/custom-command.md
vendored
Normal file
5
tests/fixtures/custom-paths/custom-commands/custom-command.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: custom-command
|
||||
---
|
||||
|
||||
Custom command
|
||||
7
tests/fixtures/custom-paths/custom-hooks/hooks.json
vendored
Normal file
7
tests/fixtures/custom-paths/custom-hooks/hooks.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{ "matcher": "Write", "hooks": [{ "type": "command", "command": "echo custom" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
5
tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md
vendored
Normal file
5
tests/fixtures/custom-paths/custom-skills/custom-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: custom-skill
|
||||
---
|
||||
|
||||
Custom skill
|
||||
7
tests/fixtures/custom-paths/hooks/hooks.json
vendored
Normal file
7
tests/fixtures/custom-paths/hooks/hooks.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"hooks": {
|
||||
"PreToolUse": [
|
||||
{ "matcher": "Read", "hooks": [{ "type": "command", "command": "echo default" }] }
|
||||
]
|
||||
}
|
||||
}
|
||||
5
tests/fixtures/custom-paths/skills/default-skill/SKILL.md
vendored
Normal file
5
tests/fixtures/custom-paths/skills/default-skill/SKILL.md
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
name: default-skill
|
||||
---
|
||||
|
||||
Default skill
|
||||
5
tests/fixtures/invalid-command-path/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/invalid-command-path/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "invalid-command-path",
|
||||
"version": "1.0.0",
|
||||
"commands": ["../outside-commands"]
|
||||
}
|
||||
5
tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/invalid-hooks-path/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "invalid-hooks-path",
|
||||
"version": "1.0.0",
|
||||
"hooks": ["../outside-hooks.json"]
|
||||
}
|
||||
5
tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/invalid-mcp-path/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "invalid-mcp-path",
|
||||
"version": "1.0.0",
|
||||
"mcpServers": ["../outside-mcp.json"]
|
||||
}
|
||||
5
tests/fixtures/mcp-file/.claude-plugin/plugin.json
vendored
Normal file
5
tests/fixtures/mcp-file/.claude-plugin/plugin.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "mcp-file",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP file fixture"
|
||||
}
|
||||
6
tests/fixtures/mcp-file/.mcp.json
vendored
Normal file
6
tests/fixtures/mcp-file/.mcp.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"remote": {
|
||||
"type": "sse",
|
||||
"url": "https://example.com/stream"
|
||||
}
|
||||
}
|
||||
30
tests/fixtures/sample-plugin/.claude-plugin/plugin.json
vendored
Normal file
30
tests/fixtures/sample-plugin/.claude-plugin/plugin.json
vendored
Normal file
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
tests/fixtures/sample-plugin/agents/agent-one.md
vendored
Normal file
10
tests/fixtures/sample-plugin/agents/agent-one.md
vendored
Normal file
@@ -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.
|
||||
7
tests/fixtures/sample-plugin/agents/security-reviewer.md
vendored
Normal file
7
tests/fixtures/sample-plugin/agents/security-reviewer.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: security-sentinel
|
||||
description: Security audits and vulnerability assessments
|
||||
model: claude-sonnet-4-20250514
|
||||
---
|
||||
|
||||
Security sentinel body.
|
||||
7
tests/fixtures/sample-plugin/commands/command-one.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/command-one.md
vendored
Normal file
@@ -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.
|
||||
8
tests/fixtures/sample-plugin/commands/model-command.md
vendored
Normal file
8
tests/fixtures/sample-plugin/commands/model-command.md
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
name: workflows:work
|
||||
description: Execute planned tasks step by step
|
||||
model: gpt-4o
|
||||
allowed-tools: WebFetch
|
||||
---
|
||||
|
||||
Workflows work body.
|
||||
9
tests/fixtures/sample-plugin/commands/nested/command-two.md
vendored
Normal file
9
tests/fixtures/sample-plugin/commands/nested/command-two.md
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
---
|
||||
name: plan_review
|
||||
description: Review a plan with multiple agents
|
||||
allowed-tools:
|
||||
- Read
|
||||
- Edit
|
||||
---
|
||||
|
||||
Plan review body.
|
||||
7
tests/fixtures/sample-plugin/commands/pattern-command.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/pattern-command.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: report-bug
|
||||
description: Report a bug with structured context
|
||||
allowed-tools: Read(.env), Bash(git:*)
|
||||
---
|
||||
|
||||
Report bug body.
|
||||
7
tests/fixtures/sample-plugin/commands/skill-command.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/skill-command.md
vendored
Normal file
@@ -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.
|
||||
7
tests/fixtures/sample-plugin/commands/todo-command.md
vendored
Normal file
7
tests/fixtures/sample-plugin/commands/todo-command.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: workflows:plan
|
||||
description: Create a structured plan from requirements
|
||||
allowed-tools: Question, TodoWrite, TodoRead
|
||||
---
|
||||
|
||||
Workflows plan body.
|
||||
156
tests/fixtures/sample-plugin/hooks/hooks.json
vendored
Normal file
156
tests/fixtures/sample-plugin/hooks/hooks.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
6
tests/fixtures/sample-plugin/skills/skill-one/SKILL.md
vendored
Normal file
6
tests/fixtures/sample-plugin/skills/skill-one/SKILL.md
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: skill-one
|
||||
description: Sample skill
|
||||
---
|
||||
|
||||
Skill body.
|
||||
20
tests/frontmatter.test.ts
Normal file
20
tests/frontmatter.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
62
tests/opencode-writer.test.ts
Normal file
62
tests/opencode-writer.test.ts
Normal file
@@ -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<boolean> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
14
tsconfig.json
Normal file
14
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user