From 1bac4995482868f455e2c29a406046d0199b2824 Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Sun, 26 Apr 2026 14:30:10 -0400 Subject: [PATCH 01/30] feat: add OpenCode integration with plugin, MCP, and skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode (opencode.ai) is a terminal-first AI coding agent that reads AGENTS.md natively and supports MCP servers. This adds `ctx setup opencode` following the Copilot CLI blueprint: a thin TypeScript plugin embedded as a static asset that shims OpenCode lifecycle hooks to ctx system subcommands. Deployed by `ctx setup opencode --write`: - .opencode/plugins/ctx/index.ts — lifecycle plugin (~35 lines) - .opencode/plugins/ctx/package.json — minimal dependencies - opencode.json — MCP server registration (merge-safe) - AGENTS.md — shared agent instructions - .opencode/skills/ctx-*/SKILL.md — 4 portable skills Plugin hooks: session.created (bootstrap), tool.execute.before (dangerous command blocking), tool.execute.after (post-commit + task completion), session.idle (persistence nudges), shell.env (CTX_DIR injection). Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Omer Kocaoglu --- docs/operations/integrations.md | 50 +++ internal/assets/commands/text/hooks.yaml | 19 + internal/assets/commands/text/write.yaml | 15 + internal/assets/embed.go | 4 + .../integrations/opencode/INSTRUCTIONS.md | 96 +++++ .../integrations/opencode/plugin/index.ts | 38 ++ .../integrations/opencode/plugin/package.json | 9 + .../opencode/skills/ctx-agent/SKILL.md | 29 ++ .../opencode/skills/ctx-remember/SKILL.md | 28 ++ .../opencode/skills/ctx-status/SKILL.md | 29 ++ .../opencode/skills/ctx-wrap-up/SKILL.md | 27 ++ internal/assets/read/agent/agent.go | 60 ++++ internal/cli/setup/cmd/root/run.go | 7 + internal/cli/setup/core/opencode/agents.go | 49 +++ internal/cli/setup/core/opencode/doc.go | 23 ++ internal/cli/setup/core/opencode/mcp.go | 81 +++++ internal/cli/setup/core/opencode/opencode.go | 58 +++ internal/cli/setup/core/opencode/plugin.go | 70 ++++ internal/cli/setup/core/opencode/skill.go | 66 ++++ internal/config/asset/asset.go | 40 ++- internal/config/embed/text/hook.go | 11 + internal/config/hook/hook.go | 17 + internal/config/setup/setup.go | 9 + internal/write/setup/hook.go | 33 ++ specs/opencode-integration.md | 334 ++++++++++++++++++ 25 files changed, 1183 insertions(+), 19 deletions(-) create mode 100644 internal/assets/integrations/opencode/INSTRUCTIONS.md create mode 100644 internal/assets/integrations/opencode/plugin/index.ts create mode 100644 internal/assets/integrations/opencode/plugin/package.json create mode 100644 internal/assets/integrations/opencode/skills/ctx-agent/SKILL.md create mode 100644 internal/assets/integrations/opencode/skills/ctx-remember/SKILL.md create mode 100644 internal/assets/integrations/opencode/skills/ctx-status/SKILL.md create mode 100644 internal/assets/integrations/opencode/skills/ctx-wrap-up/SKILL.md create mode 100644 internal/cli/setup/core/opencode/agents.go create mode 100644 internal/cli/setup/core/opencode/doc.go create mode 100644 internal/cli/setup/core/opencode/mcp.go create mode 100644 internal/cli/setup/core/opencode/opencode.go create mode 100644 internal/cli/setup/core/opencode/plugin.go create mode 100644 internal/cli/setup/core/opencode/skill.go create mode 100644 specs/opencode-integration.md diff --git a/docs/operations/integrations.md b/docs/operations/integrations.md index 272604a94..fed71c033 100644 --- a/docs/operations/integrations.md +++ b/docs/operations/integrations.md @@ -576,6 +576,56 @@ Paste output into Copilot Chat for context-aware responses. --- +## OpenCode + +OpenCode is a terminal-first AI coding agent. ctx integrates via +a thin lifecycle plugin, MCP server, and `AGENTS.md` instructions. + +### Setup + +```bash +# Generate OpenCode plugin, MCP config, skills, and AGENTS.md +ctx setup opencode --write + +# Initialize context +ctx init +eval "$(ctx activate)" +``` + +### What Gets Created + +| File | Purpose | +|------|---------| +| `.opencode/plugins/ctx/index.ts` | Lifecycle plugin (hooks to `ctx system`) | +| `.opencode/plugins/ctx/package.json` | Plugin dependencies | +| `opencode.json` | MCP server registration (merged) | +| `AGENTS.md` | Agent instructions (read natively) | +| `.opencode/skills/ctx-*/SKILL.md` | ctx skills | + +### How It Works + +The plugin wires OpenCode lifecycle events to `ctx system`: + +- **`session.created`** — bootstraps context and loads the agent packet +- **`tool.execute.before`** — blocks dangerous shell commands +- **`tool.execute.after`** — post-commit nudges and task completion checks +- **`session.idle`** — persistence and task completion nudges +- **`shell.env`** — injects `CTX_DIR=.context` + +OpenCode auto-installs plugin dependencies via `bun install`. + +### Context Updates + +```bash +# Get AI-optimized context packet +ctx agent + +# Check context health +ctx status +``` + +--- + ## Windsurf IDE Windsurf supports custom instructions and file-based context. diff --git a/internal/assets/commands/text/hooks.yaml b/internal/assets/commands/text/hooks.yaml index e329f83e2..999ae7aa8 100644 --- a/internal/assets/commands/text/hooks.yaml +++ b/internal/assets/commands/text/hooks.yaml @@ -422,6 +422,24 @@ hook.copilot-cli: Run with --write to generate all files: ctx setup copilot-cli --write +hook.opencode: + short: | + OpenCode Integration + ==================== + + Generate .opencode/plugins/ctx/ with ctx lifecycle hooks + and register the ctx MCP server in opencode.json. + + This creates: + .opencode/plugins/ctx/index.ts Plugin shim + .opencode/plugins/ctx/package.json Dependencies + .opencode/skills/ctx-*/SKILL.md ctx skills + opencode.json MCP server registration + AGENTS.md Agent instructions + + Run with --write to generate all files: + + ctx setup opencode --write hook.supported-tools: short: | Supported tools: @@ -431,6 +449,7 @@ hook.supported-tools: aider - Aider AI coding assistant copilot - GitHub Copilot (VS Code extension) copilot-cli - GitHub Copilot CLI (terminal agent) + opencode - OpenCode terminal AI agent windsurf - Windsurf IDE hook.windsurf: short: | diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 19f324c83..6a006cf94 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -112,6 +112,21 @@ write.hook-copilot-cli-summary: Hooks work on all platforms: Linux/macOS/WSL → .sh scripts Windows → .ps1 scripts +write.hook-opencode-created: + short: ' ✓ %s' +write.hook-opencode-skipped: + short: ' ○ %s (ctx plugin exists, skipped)' +write.hook-opencode-summary: + short: |- + OpenCode will now: + 1. Bootstrap ctx context on session start + 2. Block dangerous commands (tool.execute.before) + 3. Nudge persistence on session idle + 4. Track task completion after edits + + Plugin: .opencode/plugins/ctx/ + MCP: opencode.json + Skills: .opencode/skills/ write.hook-copilot-created: short: ' ✓ %s' write.hook-copilot-force-hint: diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 9616284ac..648dc9a05 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -18,6 +18,10 @@ import ( //go:embed integrations/copilot-cli/scripts/*.sh //go:embed integrations/copilot-cli/scripts/*.ps1 //go:embed integrations/copilot-cli/skills/*/SKILL.md +//go:embed integrations/opencode/*.md +//go:embed integrations/opencode/plugin/index.ts +//go:embed integrations/opencode/plugin/package.json +//go:embed integrations/opencode/skills/*/SKILL.md //go:embed hooks/messages/*/*.txt hooks/messages/registry.yaml hooks/trace/*.sh //go:embed schema/*.json why/*.md //go:embed permissions/*.txt commands/*.yaml commands/text/*.yaml journal/*.css diff --git a/internal/assets/integrations/opencode/INSTRUCTIONS.md b/internal/assets/integrations/opencode/INSTRUCTIONS.md new file mode 100644 index 000000000..4c1540517 --- /dev/null +++ b/internal/assets/integrations/opencode/INSTRUCTIONS.md @@ -0,0 +1,96 @@ +# ctx Agent Instructions: OpenCode + + + + +## IMPORTANT: You Have Persistent Memory + +This project uses Context (`ctx`) for context persistence across sessions. +**Your memory is NOT ephemeral**: it lives in the context directory. + +## On Session Start + +1. **Run `ctx system bootstrap`**: CRITICAL, not optional. + This tells you where the context directory is. If it returns any + error, relay the error output to the user verbatim, point them at + https://ctx.ist/recipes/activating-context/ for setup, and STOP. + Do not try to recover: the user decides. +2. **Read AGENT_PLAYBOOK.md** from the context directory: it explains + how to use this system +3. **Run `ctx agent`** for a content summary + +## When Asked "Do You Remember?" + +When the user asks "Do you remember?", "What were we working on?", or any +memory-related question: + +**Do this FIRST (silently):** +- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from the context directory +- Run `ctx journal source --limit 5` for recent session history + +**Then respond with a structured readback:** + +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** lead with "I don't have memory", "Let me check if there are files", +or narrate your discovery process. The context files are your memory. +Read them silently, then present what you found as recall, not as a search. + +## Quick Context Load + +```bash +# Get AI-optimized context packet (what you should know) +ctx agent + +# Or see full status +ctx status +``` + +## Context Files + +| File | Purpose | +|-----------------|----------------------------------------| +| CONSTITUTION.md | Hard rules: NEVER violate | +| TASKS.md | Current work items | +| DECISIONS.md | Architectural decisions with rationale | +| LEARNINGS.md | Gotchas, tips, lessons learned | +| CONVENTIONS.md | Code patterns and standards | + +All files live in the context directory reported by `ctx system bootstrap`. + +## Context Updates During Work + +Proactively update context files as you work: + +| Event | Action | +|-----------------------------|-------------------------------------| +| Made architectural decision | Add to `.context/DECISIONS.md` | +| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | +| Established new pattern | Add to `.context/CONVENTIONS.md` | +| Completed task | Mark [x] in `.context/TASKS.md` | + +## Self-Check + +Periodically ask yourself: + +> "If this session ended right now, would the next session know what happened?" + +If no, save a session file or update context files before continuing. + +## Session Persistence + +After completing meaningful work, save a session summary to +`.context/sessions/`. Use the `ctx-wrap-up` skill for the full ceremony. + +## Build Commands + +```bash +make build # or: go build ./cmd/ctx/... +make lint # or: golangci-lint run +make test # or: go test ./... +``` + + diff --git a/internal/assets/integrations/opencode/plugin/index.ts b/internal/assets/integrations/opencode/plugin/index.ts new file mode 100644 index 000000000..3bf64089b --- /dev/null +++ b/internal/assets/integrations/opencode/plugin/index.ts @@ -0,0 +1,38 @@ +// ctx OpenCode plugin — thin shim to ctx system subcommands. +// All real logic lives in the ctx Go binary; this plugin just +// wires OpenCode lifecycle hooks to ctx system calls. +import type { Plugin } from "@opencode-ai/plugin" + +export default ((ctx) => ({ + "shell.env": () => ({ + CTX_DIR: ".context", + }), + event: { + "session.created": async () => { + await ctx.$`ctx system bootstrap 2>/dev/null || true` + await ctx.$`ctx agent --budget 4000 2>/dev/null || true` + }, + "session.idle": async () => { + await ctx.$`ctx system check-persistence 2>/dev/null || true` + await ctx.$`ctx system check-task-completion 2>/dev/null || true` + }, + }, + "tool.execute.before": async ({ tool, input }) => { + if (tool === "shell" || tool === "bash") { + const cmd = typeof input === "string" ? input : JSON.stringify(input) + const result = + await ctx.$`echo ${cmd} | ctx system block-dangerous-commands --caller opencode 2>/dev/null` + if (result.exitCode !== 0) { + return { blocked: true, reason: result.stdout.toString().trim() } + } + } + }, + "tool.execute.after": async ({ tool }) => { + if (tool === "shell" || tool === "bash") { + await ctx.$`ctx system post-commit 2>/dev/null || true` + } + if (tool === "edit" || tool === "write" || tool === "file_edit") { + await ctx.$`ctx system check-task-completion 2>/dev/null || true` + } + }, +})) satisfies Plugin diff --git a/internal/assets/integrations/opencode/plugin/package.json b/internal/assets/integrations/opencode/plugin/package.json new file mode 100644 index 000000000..1121def32 --- /dev/null +++ b/internal/assets/integrations/opencode/plugin/package.json @@ -0,0 +1,9 @@ +{ + "name": "ctx-opencode-plugin", + "version": "0.1.0", + "type": "module", + "main": "index.ts", + "dependencies": { + "@opencode-ai/plugin": "^1.4.0" + } +} diff --git a/internal/assets/integrations/opencode/skills/ctx-agent/SKILL.md b/internal/assets/integrations/opencode/skills/ctx-agent/SKILL.md new file mode 100644 index 000000000..5943f9372 --- /dev/null +++ b/internal/assets/integrations/opencode/skills/ctx-agent/SKILL.md @@ -0,0 +1,29 @@ +--- +name: ctx-agent +description: "Load full context packet. Use at session start or when context seems stale or incomplete." +--- + +Load the full context packet for AI consumption. + +## When to Use + +- At the start of a session to load all context +- When context seems stale or incomplete +- When switching between different areas of work + +## When NOT to Use + +- The plugin hook already runs `ctx agent` on session start: + you rarely need to invoke this manually +- Don't run it just to "refresh" if you already have the context loaded in + this session + +## After Loading + +**Read the files listed in "Read These Files (in order)"**: the packet is a +summary, not a substitute. In particular, read CONVENTIONS.md before writing +any code. + +Confirm to the user: "I have read the required context files and I'm +following project conventions." Read and confirm before beginning +implementation. diff --git a/internal/assets/integrations/opencode/skills/ctx-remember/SKILL.md b/internal/assets/integrations/opencode/skills/ctx-remember/SKILL.md new file mode 100644 index 000000000..cf77e58b3 --- /dev/null +++ b/internal/assets/integrations/opencode/skills/ctx-remember/SKILL.md @@ -0,0 +1,28 @@ +--- +name: ctx-remember +description: "Recall project context and present structured readback. Use when the user asks 'do you remember?', at session start, or when context seems lost." +--- + +Recall project context and present a structured readback. + +## When to Use + +- When the user asks "Do you remember?", "What were we working on?" +- At the start of a session to pick up where you left off +- When context seems lost or stale + +## Process + +**Do this FIRST (silently):** +1. Read TASKS.md, DECISIONS.md, and LEARNINGS.md from the context directory +2. Run `ctx agent` for the full context packet + +**Then respond with a structured readback:** + +1. **Last session**: cite the most recent session topic and date +2. **Active work**: list pending or in-progress tasks +3. **Recent context**: mention 1-2 recent decisions or learnings +4. **Next step**: offer to continue or ask what to focus on + +**Never** say "I don't have memory" or narrate your discovery process. +The context files are your memory. Present what you found as recall. diff --git a/internal/assets/integrations/opencode/skills/ctx-status/SKILL.md b/internal/assets/integrations/opencode/skills/ctx-status/SKILL.md new file mode 100644 index 000000000..9b95ab742 --- /dev/null +++ b/internal/assets/integrations/opencode/skills/ctx-status/SKILL.md @@ -0,0 +1,29 @@ +--- +name: ctx-status +description: "Show context summary. Use at session start or when unclear about current project state." +--- + +Show the current context status: files, token budget, tasks, +and recent activity. + +## When to Use + +- At session start to orient before doing work +- When confused about what is being worked on or what context + exists +- To check token usage and context health +- When the user asks "what's the state of the project?" + +## When NOT to Use + +- When you already loaded context via `/ctx-agent` in this + session (status is a subset of what agent provides) +- Repeatedly within the same session without changes in between + +## Usage Examples + +```text +/ctx-status +/ctx-status --verbose +/ctx-status --json +``` diff --git a/internal/assets/integrations/opencode/skills/ctx-wrap-up/SKILL.md b/internal/assets/integrations/opencode/skills/ctx-wrap-up/SKILL.md new file mode 100644 index 000000000..4d7f16493 --- /dev/null +++ b/internal/assets/integrations/opencode/skills/ctx-wrap-up/SKILL.md @@ -0,0 +1,27 @@ +--- +name: ctx-wrap-up +description: "End-of-session context persistence ceremony. Use when wrapping up a session to capture learnings, decisions, conventions, and tasks." +--- + +Run the end-of-session context persistence ceremony. + +## When to Use + +- When ending a work session +- When switching to a different project or task area +- When context window is getting large +- Before any long break from the project + +## Process + +1. Review work done in this session +2. Capture any new decisions to `.context/DECISIONS.md` +3. Capture any new learnings to `.context/LEARNINGS.md` +4. Capture any new conventions to `.context/CONVENTIONS.md` +5. Update task status in `.context/TASKS.md` +6. Save a session summary to `.context/sessions/` + +## Self-Check + +Ask: "If this session ended right now, would the next session +know what happened?" If no, persist more context before ending. diff --git a/internal/assets/read/agent/agent.go b/internal/assets/read/agent/agent.go index 6259f2f75..66f8fc014 100644 --- a/internal/assets/read/agent/agent.go +++ b/internal/assets/read/agent/agent.go @@ -96,6 +96,66 @@ func CopilotCLIScripts() (map[string][]byte, error) { return scripts, nil } +// OpenCodePlugin reads all embedded OpenCode plugin files. +// Returns a map of filename to content for files in +// integrations/opencode/plugin/. +// +// Returns: +// - map[string][]byte: Filename -> content for each plugin file +// - error: Non-nil if the directory read fails +func OpenCodePlugin() (map[string][]byte, error) { + files := make(map[string][]byte) + entries, dirErr := fs.ReadDir( + assets.FS, asset.DirIntegrationsOpenCodePlugin) + if dirErr != nil { + return nil, dirErr + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + p := path.Join(asset.DirIntegrationsOpenCodePlugin, name) + content, readErr := assets.FS.ReadFile(p) + if readErr != nil { + return nil, readErr + } + files[name] = content + } + return files, nil +} + +// OpenCodeSkills reads all embedded OpenCode skill templates. +// Returns a map of skill directory name to SKILL.md content for skills +// in integrations/opencode/skills/. +// +// Returns: +// - map[string][]byte: Skill name -> SKILL.md content +// - error: Non-nil if the directory read fails +func OpenCodeSkills() (map[string][]byte, error) { + skills := make(map[string][]byte) + entries, dirErr := fs.ReadDir( + assets.FS, asset.DirIntegrationsOpenCodeSkill) + if dirErr != nil { + return nil, dirErr + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + name := entry.Name() + skillPath := path.Join( + asset.DirIntegrationsOpenCodeSkill, + name, asset.FileSKILLMd) + content, readErr := assets.FS.ReadFile(skillPath) + if readErr != nil { + return nil, readErr + } + skills[name] = content + } + return skills, nil +} + // CopilotCLISkills reads all embedded Copilot CLI skill templates. // Returns a map of skill directory name to SKILL.md content for skills // in integrations/copilot-cli/skills/. diff --git a/internal/cli/setup/cmd/root/run.go b/internal/cli/setup/cmd/root/run.go index 4734212ea..e6c0cf04c 100644 --- a/internal/cli/setup/cmd/root/run.go +++ b/internal/cli/setup/cmd/root/run.go @@ -20,6 +20,7 @@ import ( coreCopCLI "github.com/ActiveMemory/ctx/internal/cli/setup/core/copilot_cli" coreCursor "github.com/ActiveMemory/ctx/internal/cli/setup/core/cursor" coreKiro "github.com/ActiveMemory/ctx/internal/cli/setup/core/kiro" + coreOpenCode "github.com/ActiveMemory/ctx/internal/cli/setup/core/opencode" "github.com/ActiveMemory/ctx/internal/config/embed/text" cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" "github.com/ActiveMemory/ctx/internal/err/config" @@ -97,6 +98,12 @@ func Run(cmd *cobra.Command, args []string, writeFile bool) error { } writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookCopilotCLI)) + case cfgHook.ToolOpenCode: + if writeFile { + return coreOpenCode.Deploy(cmd) + } + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookOpenCode)) + case cfgHook.ToolWindsurf: writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookWindsurf)) diff --git a/internal/cli/setup/core/opencode/agents.go b/internal/cli/setup/core/opencode/agents.go new file mode 100644 index 000000000..06f9beef0 --- /dev/null +++ b/internal/cli/setup/core/opencode/agents.go @@ -0,0 +1,49 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "os" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + "github.com/ActiveMemory/ctx/internal/io" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// deployAgents creates AGENTS.md in the project root using the +// shared agent template. Skips if the file already exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file read or write fails +func deployAgents(cmd *cobra.Command) error { + target := cfgHook.FileAgentsMd + + if _, statErr := os.Stat(target); statErr == nil { + writeSetup.InfoOpenCodeSkipped(cmd, target) + return nil + } + + content, readErr := agent.AgentsMd() + if readErr != nil { + return readErr + } + + if wErr := io.SafeWriteFile( + target, content, fs.PermFile, + ); wErr != nil { + return wErr + } + writeSetup.InfoOpenCodeCreated(cmd, target) + return nil +} diff --git a/internal/cli/setup/core/opencode/doc.go b/internal/cli/setup/core/opencode/doc.go new file mode 100644 index 000000000..80c172564 --- /dev/null +++ b/internal/cli/setup/core/opencode/doc.go @@ -0,0 +1,23 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +// Package opencode generates OpenCode integration files during +// project setup. +// +// OpenCode is a terminal-first AI coding agent (opencode.ai). +// This package creates the configuration files that connect +// OpenCode to the ctx MCP server, deploy a thin lifecycle +// plugin, and synchronize skills. +// +// # Deployment Steps +// +// [Deploy] performs four operations in sequence: +// 1. Plugin deployment: creates .opencode/plugins/ctx/ with +// index.ts and package.json +// 2. MCP configuration: merges ctx server into opencode.json +// 3. AGENTS.md: deploys shared agent instructions +// 4. Skills: copies ctx skills to .opencode/skills/ +package opencode diff --git a/internal/cli/setup/core/opencode/mcp.go b/internal/cli/setup/core/opencode/mcp.go new file mode 100644 index 000000000..a1d2d9618 --- /dev/null +++ b/internal/cli/setup/core/opencode/mcp.go @@ -0,0 +1,81 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "encoding/json" + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" + "github.com/ActiveMemory/ctx/internal/config/token" + ctxIo "github.com/ActiveMemory/ctx/internal/io" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// ensureMCPConfig registers the ctx MCP server in opencode.json +// at the project root. +// +// Merge-safe: reads existing config, adds ctx server under +// the "mcp" key, writes back. Skips if ctx server is already +// registered. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file read/write fails +func ensureMCPConfig(cmd *cobra.Command) error { + target := cfgHook.FileOpenCodeJSON + + // Read existing config if it exists. + existing := make(map[string]interface{}) + data, readErr := ctxIo.SafeReadUserFile(target) + if readErr == nil { + if jErr := json.Unmarshal(data, &existing); jErr != nil { + return jErr + } + } + + // Get or create mcp map. + servers, _ := existing[cfgHook.KeyMCP].(map[string]interface{}) + if servers == nil { + servers = make(map[string]interface{}) + } + + // Check if ctx is already registered. + if _, ok := servers[mcpServer.Name]; ok { + writeSetup.InfoOpenCodeSkipped(cmd, target) + return nil + } + + // Add ctx MCP server. + servers[mcpServer.Name] = map[string]interface{}{ + cfgHook.KeyType: cfgHook.MCPServerType, + cfgHook.KeyCommand: mcpServer.Command, + cfgHook.KeyArgs: mcpServer.Args(), + } + existing[cfgHook.KeyMCP] = servers + + data, marshalErr := json.MarshalIndent( + existing, "", token.Indent2, + ) + if marshalErr != nil { + return marshalErr + } + data = append(data, token.NewlineLF...) + + writeFileErr := ctxIo.SafeWriteFile( + target, data, fs.PermFile, + ) + if writeFileErr != nil { + return writeFileErr + } + writeSetup.InfoOpenCodeCreated(cmd, target) + return nil +} diff --git a/internal/cli/setup/core/opencode/opencode.go b/internal/cli/setup/core/opencode/opencode.go new file mode 100644 index 000000000..3a3507e99 --- /dev/null +++ b/internal/cli/setup/core/opencode/opencode.go @@ -0,0 +1,58 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "github.com/spf13/cobra" + + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + cfgSetup "github.com/ActiveMemory/ctx/internal/config/setup" + writeErr "github.com/ActiveMemory/ctx/internal/write/err" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// Deploy generates all OpenCode integration files. +// +// Creates the .opencode/plugins/ctx/ directory with the lifecycle +// plugin, registers the ctx MCP server in opencode.json, deploys +// AGENTS.md with shared instructions, and copies ctx skills to +// .opencode/skills/. +// +// Skips existing files (idempotent). +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if plugin deployment fails (other errors are +// warned but do not halt deployment) +func Deploy(cmd *cobra.Command) error { + if pluginErr := deployPlugin(cmd); pluginErr != nil { + return pluginErr + } + + if mcpErr := ensureMCPConfig(cmd); mcpErr != nil { + writeErr.WarnFile( + cmd, cfgSetup.MCPConfigPathOpenCode, mcpErr, + ) + } + + if agentsErr := deployAgents(cmd); agentsErr != nil { + writeErr.WarnFile( + cmd, cfgHook.FileAgentsMd, agentsErr, + ) + } + + if skillErr := deploySkills(cmd); skillErr != nil { + writeErr.WarnFile( + cmd, cfgSetup.SkillsPathOpenCode, skillErr, + ) + } + + writeSetup.InfoOpenCodeSummary(cmd) + return nil +} diff --git a/internal/cli/setup/core/opencode/plugin.go b/internal/cli/setup/core/opencode/plugin.go new file mode 100644 index 000000000..d66a809c0 --- /dev/null +++ b/internal/cli/setup/core/opencode/plugin.go @@ -0,0 +1,70 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + ctxIo "github.com/ActiveMemory/ctx/internal/io" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// deployPlugin creates .opencode/plugins/ctx/ with the embedded +// plugin files (index.ts and package.json). Skips if index.ts +// already exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func deployPlugin(cmd *cobra.Command) error { + pluginDir := filepath.Join( + cfgHook.DirOpenCode, + cfgHook.DirOpenCodePlugins, + mcpServer.Name, + ) + + indexPath := filepath.Join( + pluginDir, cfgHook.FileIndexTs, + ) + if _, statErr := os.Stat(indexPath); statErr == nil { + writeSetup.InfoOpenCodeSkipped(cmd, pluginDir) + return nil + } + + if mkErr := ctxIo.SafeMkdirAll( + pluginDir, fs.PermExec, + ); mkErr != nil { + return errFs.Mkdir(pluginDir, mkErr) + } + + files, readErr := agent.OpenCodePlugin() + if readErr != nil { + return readErr + } + + for name, content := range files { + target := filepath.Join(pluginDir, name) + if wErr := ctxIo.SafeWriteFile( + target, content, fs.PermFile, + ); wErr != nil { + return errFs.FileWrite(target, wErr) + } + writeSetup.InfoOpenCodeCreated(cmd, target) + } + + return nil +} diff --git a/internal/cli/setup/core/opencode/skill.go b/internal/cli/setup/core/opencode/skill.go new file mode 100644 index 000000000..fc6f5bbf3 --- /dev/null +++ b/internal/cli/setup/core/opencode/skill.go @@ -0,0 +1,66 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/config/fs" + cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" + errFs "github.com/ActiveMemory/ctx/internal/err/fs" + ctxIo "github.com/ActiveMemory/ctx/internal/io" + writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" +) + +// deploySkills creates .opencode/skills//SKILL.md for each +// embedded OpenCode skill. Skips skills whose SKILL.md already +// exists. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if directory creation or file write fails +func deploySkills(cmd *cobra.Command) error { + skills, readErr := agent.OpenCodeSkills() + if readErr != nil { + return readErr + } + + skillsBase := filepath.Join( + cfgHook.DirOpenCode, cfgHook.DirOpenCodeSkills, + ) + + for name, content := range skills { + skillDir := filepath.Join(skillsBase, name) + target := filepath.Join(skillDir, cfgHook.FileSKILLMd) + + if _, statErr := os.Stat(target); statErr == nil { + writeSetup.InfoOpenCodeSkipped(cmd, target) + continue + } + + if mkErr := ctxIo.SafeMkdirAll( + skillDir, fs.PermExec, + ); mkErr != nil { + return errFs.Mkdir(skillDir, mkErr) + } + + if wErr := ctxIo.SafeWriteFile( + target, content, fs.PermFile, + ); wErr != nil { + return errFs.FileWrite(target, wErr) + } + writeSetup.InfoOpenCodeCreated(cmd, target) + } + + return nil +} diff --git a/internal/config/asset/asset.go b/internal/config/asset/asset.go index 48be0af09..6bc039319 100644 --- a/internal/config/asset/asset.go +++ b/internal/config/asset/asset.go @@ -10,25 +10,27 @@ import "path" // Embedded asset directory names. const ( - DirClaude = "claude" - DirClaudePlugin = "claude/.claude-plugin" - DirClaudeSkills = "claude/skills" - DirCommands = "commands" - DirCommandsText = "commands/text" - DirContext = "context" - DirEntryTemplates = "entry-templates" - DirIntegrations = "integrations" - DirIntegrationsCopilot = "integrations/copilot" - DirIntegrationsCopilotCLI = "integrations/copilot-cli" - DirIntegrationsCopilotScrp = "integrations/copilot-cli/scripts" - DirIntegrationsCopilotSkill = "integrations/copilot-cli/skills" - DirHooksMessages = "hooks/messages" - DirHooksTrace = "hooks/trace" - DirJournal = "journal" - DirPermissions = "permissions" - DirProject = "project" - DirSchema = "schema" - DirWhy = "why" + DirClaude = "claude" + DirClaudePlugin = "claude/.claude-plugin" + DirClaudeSkills = "claude/skills" + DirCommands = "commands" + DirCommandsText = "commands/text" + DirContext = "context" + DirEntryTemplates = "entry-templates" + DirIntegrations = "integrations" + DirIntegrationsCopilot = "integrations/copilot" + DirIntegrationsCopilotCLI = "integrations/copilot-cli" + DirIntegrationsCopilotScrp = "integrations/copilot-cli/scripts" + DirIntegrationsCopilotSkill = "integrations/copilot-cli/skills" + DirIntegrationsOpenCodePlugin = "integrations/opencode/plugin" + DirIntegrationsOpenCodeSkill = "integrations/opencode/skills" + DirHooksMessages = "hooks/messages" + DirHooksTrace = "hooks/trace" + DirJournal = "journal" + DirPermissions = "permissions" + DirProject = "project" + DirSchema = "schema" + DirWhy = "why" ) // JSON field keys used when parsing embedded asset files. diff --git a/internal/config/embed/text/hook.go b/internal/config/embed/text/hook.go index e077269cd..8636fe4e1 100644 --- a/internal/config/embed/text/hook.go +++ b/internal/config/embed/text/hook.go @@ -16,6 +16,8 @@ const ( DescKeyHookCopilot = "hook.copilot" // DescKeyHookCopilotCLI is the text key for hook copilot cli messages. DescKeyHookCopilotCLI = "hook.copilot-cli" + // DescKeyHookOpenCode is the text key for hook opencode messages. + DescKeyHookOpenCode = "hook.opencode" // DescKeyHookSupportedTools is the text key for hook supported tools messages. DescKeyHookSupportedTools = "hook.supported-tools" // DescKeyHookWindsurf is the text key for hook windsurf messages. @@ -63,6 +65,15 @@ const ( // DescKeyWriteHookCopilotSummary is the text key for write hook copilot // summary messages. DescKeyWriteHookCopilotSummary = "write.hook-copilot-summary" + // DescKeyWriteHookOpenCodeCreated is the text key for write hook opencode + // created messages. + DescKeyWriteHookOpenCodeCreated = "write.hook-opencode-created" + // DescKeyWriteHookOpenCodeSkipped is the text key for write hook opencode + // skipped messages. + DescKeyWriteHookOpenCodeSkipped = "write.hook-opencode-skipped" + // DescKeyWriteHookOpenCodeSummary is the text key for write hook opencode + // summary messages. + DescKeyWriteHookOpenCodeSummary = "write.hook-opencode-summary" // DescKeyWriteHookUnknownTool is the text key for write hook unknown tool // messages. DescKeyWriteHookUnknownTool = "write.hook-unknown-tool" diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 9099a69cc..7a9ca4655 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -66,6 +66,7 @@ const ( ToolKiro = "kiro" ToolCline = "cline" ToolCodex = "codex" + ToolOpenCode = "opencode" ToolWindsurf = "windsurf" ) @@ -109,6 +110,22 @@ const ( ToolsWildcard = "*" ) +// OpenCode integration paths. +const ( + // DirOpenCode is the OpenCode project config directory. + DirOpenCode = ".opencode" + // DirOpenCodePlugins is the OpenCode plugins subdirectory. + DirOpenCodePlugins = "plugins" + // DirOpenCodeSkills is the OpenCode skills subdirectory. + DirOpenCodeSkills = "skills" + // FileOpenCodeJSON is the OpenCode project config file. + FileOpenCodeJSON = "opencode.json" + // KeyMCP is the top-level JSON key for MCP in opencode.json. + KeyMCP = "mcp" + // FileIndexTs is the OpenCode plugin entry point file. + FileIndexTs = "index.ts" +) + // Prefixes const ( // StdinReadTimeout is the maximum time to wait for hook JSON on stdin diff --git a/internal/config/setup/setup.go b/internal/config/setup/setup.go index 4a4830a23..198c2ffda 100644 --- a/internal/config/setup/setup.go +++ b/internal/config/setup/setup.go @@ -44,6 +44,15 @@ const ( SteeringPathCursor = DirCursor + "/rules/" ) +// OpenCode configuration paths. +const ( + // MCPConfigPathOpenCode is the OpenCode MCP config path. + MCPConfigPathOpenCode = "opencode.json" + // SkillsPathOpenCode is the deployed skills path + // for OpenCode. + SkillsPathOpenCode = ".opencode/skills/" +) + // Cline configuration paths. const ( // MCPConfigPathCline is the deployed MCP config path. diff --git a/internal/write/setup/hook.go b/internal/write/setup/hook.go index e2d5908bb..8ec4929c1 100644 --- a/internal/write/setup/hook.go +++ b/internal/write/setup/hook.go @@ -191,6 +191,39 @@ func InfoAgentsSummary(cmd *cobra.Command) { cmd.Println(desc.Text(text.DescKeyWriteHookAgentsSummary)) } +// InfoOpenCodeCreated reports that an OpenCode integration file was +// created. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the created file +func InfoOpenCodeCreated(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteHookOpenCodeCreated), + targetFile)) +} + +// InfoOpenCodeSkipped reports that an OpenCode integration file was +// skipped because it already exists. +// +// Parameters: +// - cmd: Cobra command for output +// - targetFile: Path to the existing file +func InfoOpenCodeSkipped(cmd *cobra.Command, targetFile string) { + cmd.Println(fmt.Sprintf( + desc.Text(text.DescKeyWriteHookOpenCodeSkipped), + targetFile)) +} + +// InfoOpenCodeSummary prints the post-write summary for OpenCode. +// +// Parameters: +// - cmd: Cobra command for output +func InfoOpenCodeSummary(cmd *cobra.Command) { + cmd.Println() + cmd.Println(desc.Text(text.DescKeyWriteHookOpenCodeSummary)) +} + // InfoCopilotCLISkipped reports that copilot-cli hooks were skipped // because they already exist. // diff --git a/specs/opencode-integration.md b/specs/opencode-integration.md new file mode 100644 index 000000000..3a940335a --- /dev/null +++ b/specs/opencode-integration.md @@ -0,0 +1,334 @@ +# Spec: OpenCode Integration for ctx + +## Context + +OpenCode (opencode.ai) is a terminal-first AI coding agent with 140K+ GitHub stars. +It reads `AGENTS.md` natively, supports MCP servers via `opencode.json`, and has a +plugin system (`@opencode-ai/plugin`) with lifecycle hooks. ctx already mentions +OpenCode as an `AGENTS.md`-compatible tool (`hooks.yaml:373`) but has no dedicated +integration. + +**Goal:** Add `ctx setup opencode --write` following the Copilot CLI blueprint — +MCP registration, `AGENTS.md` generation, a thin TypeScript plugin (embedded asset, +not an npm dependency) that shims lifecycle hooks to `ctx system` subcommands, and +OpenCode-native skills. + +**Why this shape:** Every ctx integration is a Go package that deploys config + +assets. The TypeScript plugin is a static embedded asset (like Copilot CLI's `.sh` +scripts) — not a build dependency. All real logic stays in Go via `ctx system`. + +--- + +## Files to Create + +### 1. Embedded Assets (`internal/assets/integrations/opencode/`) + +``` +internal/assets/integrations/opencode/ +├── plugin/ +│ ├── index.ts # Thin shim plugin (~40 lines) +│ └── package.json # Minimal: name, version, @opencode-ai/plugin dep +├── INSTRUCTIONS.md # OpenCode-specific agent instructions (adapt from copilot-cli) +└── skills/ # ctx skills in OpenCode format + ├── ctx-agent/SKILL.md + ├── ctx-remember/SKILL.md + ├── ctx-status/SKILL.md + └── ctx-wrap-up/SKILL.md +``` + +**`plugin/index.ts`** — the core deliverable: +```typescript +import type { Plugin } from "@opencode-ai/plugin" + +export default ((ctx) => ({ + "shell.env": () => ({ + CTX_DIR: ".context", + }), + event: { + "session.created": async () => { + await ctx.$`ctx system bootstrap 2>/dev/null || true` + await ctx.$`ctx agent --budget 4000 2>/dev/null || true` + }, + "session.idle": async () => { + await ctx.$`ctx system check-persistence 2>/dev/null || true` + await ctx.$`ctx system check-task-completion 2>/dev/null || true` + }, + }, + "tool.execute.before": async ({ tool, input }) => { + if (tool === "shell" || tool === "bash") { + const cmd = typeof input === "string" ? input : JSON.stringify(input) + const result = await ctx.$`echo ${cmd} | ctx system block-dangerous-commands --caller opencode 2>/dev/null` + if (result.exitCode !== 0) { + return { blocked: true, reason: result.stdout.toString().trim() } + } + } + }, + "tool.execute.after": async ({ tool }) => { + if (tool === "shell" || tool === "bash") { + await ctx.$`ctx system post-commit 2>/dev/null || true` + } + if (tool === "edit" || tool === "write" || tool === "file_edit") { + await ctx.$`ctx system check-task-completion 2>/dev/null || true` + } + }, +})) satisfies Plugin +``` + +**`plugin/package.json`**: +```json +{ + "name": "ctx-opencode-plugin", + "version": "0.1.0", + "type": "module", + "main": "index.ts", + "dependencies": { + "@opencode-ai/plugin": "^1.4.0" + } +} +``` + +**`INSTRUCTIONS.md`** — Adapt from `copilot-cli/INSTRUCTIONS.md`, replacing +Copilot CLI specifics with OpenCode equivalents. Same ctx session protocol. + +**`skills/`** — Subset of portable skills (ctx-agent, ctx-remember, ctx-status, +ctx-wrap-up). Format: YAML frontmatter + markdown body, same as Copilot CLI skills. + +### 2. Asset Reader (`internal/assets/read/agent/agent.go`) + +Add functions (following `CopilotCLI*` pattern): + +```go +// OpenCodePlugin reads the embedded OpenCode plugin directory. +func OpenCodePlugin() (map[string][]byte, error) // filename -> content + +// OpenCodeInstructions reads INSTRUCTIONS.md for OpenCode. +func OpenCodeInstructions() ([]byte, error) + +// OpenCodeSkills reads embedded OpenCode skill templates. +func OpenCodeSkills() (map[string][]byte, error) // skill name -> SKILL.md +``` + +### 3. Asset Path Constants (`internal/config/asset/asset.go`) + +Add: +```go +DirIntegrationsOpenCode = "integrations/opencode" +DirIntegrationsOpenCodePlugin = "integrations/opencode/plugin" +DirIntegrationsOpenCodeSkill = "integrations/opencode/skills" +``` + +### 4. Hook Constants (`internal/config/hook/hook.go`) + +Add to tool constants: +```go +ToolOpenCode = "opencode" +``` + +Add OpenCode integration path constants: +```go +// OpenCode integration paths. +DirOpenCode = ".opencode" +DirOpenCodePlugins = "plugins" +DirOpenCodeSkills = "skills" +FileOpenCodeJSON = "opencode.json" +``` + +### 5. Setup Path Constants (`internal/config/setup/setup.go`) + +Add: +```go +DisplayOpenCode = "OpenCode" +MCPConfigPathOpenCode = "opencode.json" +PluginPathOpenCode = ".opencode/plugins/ctx/" +SkillsPathOpenCode = ".opencode/skills/" +``` + +### 6. Text Description Keys (`internal/config/embed/text/hook.go`) + +Add: +```go +DescKeyHookOpenCode = "hook.opencode" +DescKeyWriteHookOpenCodeCreated = "write.hook-opencode-created" +DescKeyWriteHookOpenCodeSkipped = "write.hook-opencode-skipped" +DescKeyWriteHookOpenCodeSummary = "write.hook-opencode-summary" +``` + +### 7. YAML Text Templates + +**`hooks.yaml`** — add `hook.opencode`: +```yaml +hook.opencode: + short: | + OpenCode Integration + ==================== + + Generate .opencode/plugins/ctx/ with ctx lifecycle hooks + and register the ctx MCP server in opencode.json. + + This creates: + .opencode/plugins/ctx/index.ts Plugin shim + .opencode/plugins/ctx/package.json Dependencies + .opencode/skills/ctx-*/SKILL.md ctx skills + opencode.json MCP server registration + + Run with --write to generate all files: + + ctx setup opencode --write +``` + +Update `hook.supported-tools` to include `opencode`. + +**`write.yaml`** — add: +```yaml +write.hook-opencode-created: + short: ' ✓ %s' +write.hook-opencode-skipped: + short: ' ○ %s (ctx plugin exists, skipped)' +write.hook-opencode-summary: + short: |- + OpenCode will now: + 1. Bootstrap ctx context on session start + 2. Block dangerous commands (tool.execute.before) + 3. Nudge persistence on session idle + 4. Track task completion after edits +``` + +### 8. Setup Core Package (`internal/cli/setup/core/opencode/`) + +``` +internal/cli/setup/core/opencode/ +├── doc.go # Package documentation +├── opencode.go # Deploy() entry point +├── plugin.go # deployPlugin() — writes .opencode/plugins/ctx/ +├── mcp.go # ensureMCPConfig() — merges opencode.json +├── skill.go # deploySkills() — writes .opencode/skills/ +└── agents.go # deployAgents() — writes AGENTS.md (shared template) +``` + +**`opencode.go` — Deploy()**: +```go +func Deploy(cmd *cobra.Command) error { + // 1. Deploy plugin files (.opencode/plugins/ctx/) + if pluginErr := deployPlugin(cmd); pluginErr != nil { + return pluginErr + } + // 2. Register MCP server in opencode.json + if mcpErr := ensureMCPConfig(cmd); mcpErr != nil { + writeErr.WarnFile(cmd, cfgSetup.MCPConfigPathOpenCode, mcpErr) + } + // 3. Deploy AGENTS.md (shared template, idempotent) + if agentsErr := deployAgents(cmd); agentsErr != nil { + writeErr.WarnFile(cmd, cfgHook.FileAgentsMd, agentsErr) + } + // 4. Deploy skills to .opencode/skills/ + if skillErr := deploySkills(cmd); skillErr != nil { + writeErr.WarnFile(cmd, cfgSetup.SkillsPathOpenCode, skillErr) + } + writeSetup.InfoOpenCodeSummary(cmd) + return nil +} +``` + +**`mcp.go` — ensureMCPConfig()**: + +OpenCode MCP config lives in `opencode.json` at project root: +```json +{ + "mcp": { + "ctx": { + "type": "local", + "command": "ctx", + "args": ["mcp", "serve"] + } + } +} +``` + +Read-merge-write pattern: read existing `opencode.json`, add/update `mcp.ctx` +entry, write back. Preserve all other config keys. + +**`plugin.go` — deployPlugin()**: + +Extract embedded `index.ts` and `package.json` to `.opencode/plugins/ctx/`. +Skip if `index.ts` already exists (idempotent). OpenCode auto-runs +`bun install` in plugin directories at startup. + +### 9. Write Setup Functions (`internal/write/setup/hook.go`) + +Add: +```go +func InfoOpenCodeCreated(cmd *cobra.Command, targetFile string) +func InfoOpenCodeSkipped(cmd *cobra.Command, targetFile string) +func InfoOpenCodeSummary(cmd *cobra.Command) +``` + +### 10. Tool Dispatcher (`internal/cli/setup/cmd/root/run.go`) + +Add import and case: +```go +coreOpenCode "github.com/ActiveMemory/ctx/internal/cli/setup/core/opencode" + +case cfgHook.ToolOpenCode: + if writeFile { + return coreOpenCode.Deploy(cmd) + } + writeSetup.InfoTool(cmd, desc.Text(text.DescKeyHookOpenCode)) +``` + +--- + +## Files to Modify + +| File | Change | +|------|--------| +| `internal/config/hook/hook.go` | Add `ToolOpenCode` + OpenCode path constants | +| `internal/config/setup/setup.go` | Add `DisplayOpenCode` + path constants | +| `internal/config/asset/asset.go` | Add `DirIntegrationsOpenCode*` constants | +| `internal/config/embed/text/hook.go` | Add `DescKeyHookOpenCode` + write keys | +| `internal/assets/commands/text/hooks.yaml` | Add `hook.opencode` + update supported-tools | +| `internal/assets/commands/text/write.yaml` | Add `write.hook-opencode-*` entries | +| `internal/assets/read/agent/agent.go` | Add `OpenCode*()` reader functions | +| `internal/write/setup/hook.go` | Add `InfoOpenCode*()` output functions | +| `internal/cli/setup/cmd/root/run.go` | Add `opencode` case to switch | +| `docs/operations/integrations.md` | Add OpenCode section | + +--- + +## What We're NOT Doing + +- No steering sync for OpenCode (OpenCode doesn't have a native rules format + like `.cursor/rules/`; it uses `AGENTS.md` + skills instead) +- No `ctx init` changes (OpenCode reads `AGENTS.md` which `ctx setup agents` + already generates; `ctx setup opencode` handles the rest) +- No npm publish (plugin is embedded in the Go binary, deployed by setup) +- No session parser (OpenCode session format is TBD; add later when stable) + +--- + +## Implementation Order + +1. **Constants** — `hook.go`, `setup.go`, `asset.go`, `text/hook.go` +2. **Embedded assets** — `integrations/opencode/` directory with plugin, instructions, skills +3. **Asset readers** — `agent.go` OpenCode functions +4. **Setup core** — `internal/cli/setup/core/opencode/` package (5 files) +5. **Write functions** — `write/setup/hook.go` additions +6. **Dispatcher** — `run.go` case addition +7. **YAML text** — `hooks.yaml` + `write.yaml` entries +8. **Docs** — `integrations.md` OpenCode section + +--- + +## Verification + +1. **Build**: `make build` — verify compilation with new package +2. **Dry run**: `ctx setup opencode` — should print integration instructions +3. **Write**: `ctx setup opencode --write` in a test project — verify: + - `.opencode/plugins/ctx/index.ts` created + - `.opencode/plugins/ctx/package.json` created + - `opencode.json` has `mcp.ctx` entry (merged, not overwritten) + - `AGENTS.md` created (or skipped if exists with markers) + - `.opencode/skills/ctx-*/SKILL.md` created +4. **Idempotency**: Run `ctx setup opencode --write` twice — second run skips existing +5. **Lint**: `make lint` +6. **Test**: `make test` +7. **Smoke**: `make smoke` From c347675163370901a4543ba57c6d96ee0d9e17d2 Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Sun, 26 Apr 2026 15:19:55 -0400 Subject: [PATCH 02/30] fix(opencode): drop broken dangerous-command hook, narrow post-commit, add tests The original plugin called `ctx system block-dangerous-commands`, which is not a real subcommand on the ctx Go binary (it's a Claude-Code plugin-local hook). On any install without that wrapper Cobra returns exit 1, the plugin reads that as `{ blocked: true }`, and OpenCode blocks every shell tool call. Pulling the `tool.execute.before` hook until block-dangerous- commands is promoted into the Go binary. Other fixes in the same pass: - Narrow `post-commit` to actual `git commit` invocations via a regex with a negative lookahead so `git commit-tree` / `commit-graph` don't trigger it. The previous code ran post-commit after every shell tool. - Drop the embedded `INSTRUCTIONS.md` asset that nothing read; AGENTS.md is what's actually deployed for OpenCode. - Treat empty / whitespace-only `opencode.json` as "no existing config" in `ensureMCPConfig`; previously a pre-created empty file made setup hard-error on unmarshal. - Tighten `extractCommand` to read `{command: string}` shapes instead of JSON-stringifying arbitrary input into the dangerous-command pipe. - Add `mcp_test.go` covering create / empty-file / preserve-keys / skip-if-registered / reject-malformed-JSON; add `testmain_test.go`. - Update user-facing summary text and integration docs to match the shipped behavior (drop "blocks dangerous commands" claim, document `bun install` step). - Refresh `specs/opencode-integration.md` to match the landed code and record why we deliberately skip `tool.execute.before`. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Omer Kocaoglu --- docs/operations/integrations.md | 9 +- internal/assets/commands/text/write.yaml | 6 +- internal/assets/embed.go | 1 - .../integrations/opencode/INSTRUCTIONS.md | 96 ----------- .../integrations/opencode/plugin/index.ts | 38 ++-- internal/cli/setup/core/opencode/mcp.go | 17 +- internal/cli/setup/core/opencode/mcp_test.go | 163 ++++++++++++++++++ .../cli/setup/core/opencode/testmain_test.go | 19 ++ specs/opencode-integration.md | 69 +++----- 9 files changed, 248 insertions(+), 170 deletions(-) delete mode 100644 internal/assets/integrations/opencode/INSTRUCTIONS.md create mode 100644 internal/cli/setup/core/opencode/mcp_test.go create mode 100644 internal/cli/setup/core/opencode/testmain_test.go diff --git a/docs/operations/integrations.md b/docs/operations/integrations.md index fed71c033..39fdce7cb 100644 --- a/docs/operations/integrations.md +++ b/docs/operations/integrations.md @@ -607,12 +607,15 @@ eval "$(ctx activate)" The plugin wires OpenCode lifecycle events to `ctx system`: - **`session.created`** — bootstraps context and loads the agent packet -- **`tool.execute.before`** — blocks dangerous shell commands -- **`tool.execute.after`** — post-commit nudges and task completion checks +- **`tool.execute.after` (shell, on `git commit`)** — runs `ctx system post-commit` +- **`tool.execute.after` (edit/write)** — `check-task-completion` nudge - **`session.idle`** — persistence and task completion nudges - **`shell.env`** — injects `CTX_DIR=.context` -OpenCode auto-installs plugin dependencies via `bun install`. +After running `ctx setup opencode --write`, run `bun install` inside +`.opencode/plugins/ctx/` (or let OpenCode do it on first launch — see +the [OpenCode plugin docs](https://opencode.ai/docs/plugins/) for the +current behavior). ### Context Updates diff --git a/internal/assets/commands/text/write.yaml b/internal/assets/commands/text/write.yaml index 6a006cf94..36408b7f7 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -120,9 +120,9 @@ write.hook-opencode-summary: short: |- OpenCode will now: 1. Bootstrap ctx context on session start - 2. Block dangerous commands (tool.execute.before) - 3. Nudge persistence on session idle - 4. Track task completion after edits + 2. Nudge persistence on session idle + 3. Track task completion after edits + 4. Run post-commit capture after `git commit` Plugin: .opencode/plugins/ctx/ MCP: opencode.json diff --git a/internal/assets/embed.go b/internal/assets/embed.go index 648dc9a05..f1d9a44b9 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -18,7 +18,6 @@ import ( //go:embed integrations/copilot-cli/scripts/*.sh //go:embed integrations/copilot-cli/scripts/*.ps1 //go:embed integrations/copilot-cli/skills/*/SKILL.md -//go:embed integrations/opencode/*.md //go:embed integrations/opencode/plugin/index.ts //go:embed integrations/opencode/plugin/package.json //go:embed integrations/opencode/skills/*/SKILL.md diff --git a/internal/assets/integrations/opencode/INSTRUCTIONS.md b/internal/assets/integrations/opencode/INSTRUCTIONS.md deleted file mode 100644 index 4c1540517..000000000 --- a/internal/assets/integrations/opencode/INSTRUCTIONS.md +++ /dev/null @@ -1,96 +0,0 @@ -# ctx Agent Instructions: OpenCode - - - - -## IMPORTANT: You Have Persistent Memory - -This project uses Context (`ctx`) for context persistence across sessions. -**Your memory is NOT ephemeral**: it lives in the context directory. - -## On Session Start - -1. **Run `ctx system bootstrap`**: CRITICAL, not optional. - This tells you where the context directory is. If it returns any - error, relay the error output to the user verbatim, point them at - https://ctx.ist/recipes/activating-context/ for setup, and STOP. - Do not try to recover: the user decides. -2. **Read AGENT_PLAYBOOK.md** from the context directory: it explains - how to use this system -3. **Run `ctx agent`** for a content summary - -## When Asked "Do You Remember?" - -When the user asks "Do you remember?", "What were we working on?", or any -memory-related question: - -**Do this FIRST (silently):** -- Read TASKS.md, DECISIONS.md, and LEARNINGS.md from the context directory -- Run `ctx journal source --limit 5` for recent session history - -**Then respond with a structured readback:** - -1. **Last session**: cite the most recent session topic and date -2. **Active work**: list pending or in-progress tasks -3. **Recent context**: mention 1-2 recent decisions or learnings -4. **Next step**: offer to continue or ask what to focus on - -**Never** lead with "I don't have memory", "Let me check if there are files", -or narrate your discovery process. The context files are your memory. -Read them silently, then present what you found as recall, not as a search. - -## Quick Context Load - -```bash -# Get AI-optimized context packet (what you should know) -ctx agent - -# Or see full status -ctx status -``` - -## Context Files - -| File | Purpose | -|-----------------|----------------------------------------| -| CONSTITUTION.md | Hard rules: NEVER violate | -| TASKS.md | Current work items | -| DECISIONS.md | Architectural decisions with rationale | -| LEARNINGS.md | Gotchas, tips, lessons learned | -| CONVENTIONS.md | Code patterns and standards | - -All files live in the context directory reported by `ctx system bootstrap`. - -## Context Updates During Work - -Proactively update context files as you work: - -| Event | Action | -|-----------------------------|-------------------------------------| -| Made architectural decision | Add to `.context/DECISIONS.md` | -| Discovered gotcha/bug | Add to `.context/LEARNINGS.md` | -| Established new pattern | Add to `.context/CONVENTIONS.md` | -| Completed task | Mark [x] in `.context/TASKS.md` | - -## Self-Check - -Periodically ask yourself: - -> "If this session ended right now, would the next session know what happened?" - -If no, save a session file or update context files before continuing. - -## Session Persistence - -After completing meaningful work, save a session summary to -`.context/sessions/`. Use the `ctx-wrap-up` skill for the full ceremony. - -## Build Commands - -```bash -make build # or: go build ./cmd/ctx/... -make lint # or: golangci-lint run -make test # or: go test ./... -``` - - diff --git a/internal/assets/integrations/opencode/plugin/index.ts b/internal/assets/integrations/opencode/plugin/index.ts index 3bf64089b..d7fc76184 100644 --- a/internal/assets/integrations/opencode/plugin/index.ts +++ b/internal/assets/integrations/opencode/plugin/index.ts @@ -1,8 +1,27 @@ // ctx OpenCode plugin — thin shim to ctx system subcommands. // All real logic lives in the ctx Go binary; this plugin just // wires OpenCode lifecycle hooks to ctx system calls. +// +// Tool names below match @opencode-ai/plugin v1.4.x. If the +// upstream renames a tool, the corresponding branch silently +// no-ops; verify against the OpenCode plugin docs when bumping. import type { Plugin } from "@opencode-ai/plugin" +const SHELL_TOOLS = new Set(["shell", "bash"]) +const EDIT_TOOLS = new Set(["edit", "write", "file_edit"]) +// Match `git commit` but not `git commit-tree` / `git commit-graph`. +// The negative lookahead rejects `-` immediately after the boundary. +const GIT_COMMIT_RE = /\bgit\s+commit\b(?!-)/ + +function extractCommand(input: unknown): string { + if (typeof input === "string") return input + if (input && typeof input === "object") { + const cmd = (input as { command?: unknown }).command + if (typeof cmd === "string") return cmd + } + return "" +} + export default ((ctx) => ({ "shell.env": () => ({ CTX_DIR: ".context", @@ -17,21 +36,14 @@ export default ((ctx) => ({ await ctx.$`ctx system check-task-completion 2>/dev/null || true` }, }, - "tool.execute.before": async ({ tool, input }) => { - if (tool === "shell" || tool === "bash") { - const cmd = typeof input === "string" ? input : JSON.stringify(input) - const result = - await ctx.$`echo ${cmd} | ctx system block-dangerous-commands --caller opencode 2>/dev/null` - if (result.exitCode !== 0) { - return { blocked: true, reason: result.stdout.toString().trim() } + "tool.execute.after": async ({ tool, input }) => { + if (SHELL_TOOLS.has(tool)) { + const cmd = extractCommand(input) + if (GIT_COMMIT_RE.test(cmd)) { + await ctx.$`ctx system post-commit 2>/dev/null || true` } } - }, - "tool.execute.after": async ({ tool }) => { - if (tool === "shell" || tool === "bash") { - await ctx.$`ctx system post-commit 2>/dev/null || true` - } - if (tool === "edit" || tool === "write" || tool === "file_edit") { + if (EDIT_TOOLS.has(tool)) { await ctx.$`ctx system check-task-completion 2>/dev/null || true` } }, diff --git a/internal/cli/setup/core/opencode/mcp.go b/internal/cli/setup/core/opencode/mcp.go index a1d2d9618..06a4ebe81 100644 --- a/internal/cli/setup/core/opencode/mcp.go +++ b/internal/cli/setup/core/opencode/mcp.go @@ -7,7 +7,9 @@ package opencode import ( + "bytes" "encoding/json" + "github.com/spf13/cobra" "github.com/ActiveMemory/ctx/internal/config/fs" @@ -23,7 +25,8 @@ import ( // // Merge-safe: reads existing config, adds ctx server under // the "mcp" key, writes back. Skips if ctx server is already -// registered. +// registered. Treats a missing or empty file as "no existing +// config" rather than an error. // // Parameters: // - cmd: Cobra command for output messages @@ -33,10 +36,12 @@ import ( func ensureMCPConfig(cmd *cobra.Command) error { target := cfgHook.FileOpenCodeJSON - // Read existing config if it exists. + // Read existing config if it exists. An empty or whitespace-only + // file is treated as "no existing config" so users who pre-create + // opencode.json don't trip an unmarshal error. existing := make(map[string]interface{}) data, readErr := ctxIo.SafeReadUserFile(target) - if readErr == nil { + if readErr == nil && len(bytes.TrimSpace(data)) > 0 { if jErr := json.Unmarshal(data, &existing); jErr != nil { return jErr } @@ -62,16 +67,16 @@ func ensureMCPConfig(cmd *cobra.Command) error { } existing[cfgHook.KeyMCP] = servers - data, marshalErr := json.MarshalIndent( + out, marshalErr := json.MarshalIndent( existing, "", token.Indent2, ) if marshalErr != nil { return marshalErr } - data = append(data, token.NewlineLF...) + out = append(out, token.NewlineLF...) writeFileErr := ctxIo.SafeWriteFile( - target, data, fs.PermFile, + target, out, fs.PermFile, ) if writeFileErr != nil { return writeFileErr diff --git a/internal/cli/setup/core/opencode/mcp_test.go b/internal/cli/setup/core/opencode/mcp_test.go new file mode 100644 index 000000000..47148b3b8 --- /dev/null +++ b/internal/cli/setup/core/opencode/mcp_test.go @@ -0,0 +1,163 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "bytes" + "encoding/json" + "os" + "testing" + + "github.com/spf13/cobra" +) + +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func chdirTemp(t *testing.T) { + t.Helper() + tmp := t.TempDir() + orig, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(orig) }) +} + +func readMCP(t *testing.T) map[string]interface{} { + t.Helper() + raw, err := os.ReadFile("opencode.json") + if err != nil { + t.Fatalf("read opencode.json: %v", err) + } + parsed := map[string]interface{}{} + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("opencode.json not valid JSON: %v", err) + } + return parsed +} + +func TestEnsureMCPConfig_CreatesFile(t *testing.T) { + chdirTemp(t) + + var buf bytes.Buffer + if err := ensureMCPConfig(testCmd(&buf)); err != nil { + t.Fatalf("ensureMCPConfig: %v", err) + } + + parsed := readMCP(t) + servers, ok := parsed["mcp"].(map[string]interface{}) + if !ok { + t.Fatal("missing mcp key") + } + ctxServer, ok := servers["ctx"].(map[string]interface{}) + if !ok { + t.Fatal("missing mcp.ctx key") + } + if ctxServer["command"] != "ctx" { + t.Errorf("command = %q, want ctx", ctxServer["command"]) + } + if ctxServer["type"] != "local" { + t.Errorf("type = %q, want local", ctxServer["type"]) + } +} + +func TestEnsureMCPConfig_TreatsEmptyFileAsAbsent(t *testing.T) { + chdirTemp(t) + + if err := os.WriteFile( + "opencode.json", []byte(" \n\t "), 0o644, + ); err != nil { + t.Fatalf("seed empty file: %v", err) + } + + var buf bytes.Buffer + if err := ensureMCPConfig(testCmd(&buf)); err != nil { + t.Fatalf("ensureMCPConfig on empty file: %v", err) + } + + parsed := readMCP(t) + if _, ok := parsed["mcp"].(map[string]interface{}); !ok { + t.Fatal("mcp key not registered after empty-file path") + } +} + +func TestEnsureMCPConfig_PreservesExistingKeys(t *testing.T) { + chdirTemp(t) + + seed := []byte(`{"theme":"dark","mcp":{"other":{"type":"local"}}}`) + if err := os.WriteFile("opencode.json", seed, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + var buf bytes.Buffer + if err := ensureMCPConfig(testCmd(&buf)); err != nil { + t.Fatalf("ensureMCPConfig: %v", err) + } + + parsed := readMCP(t) + if parsed["theme"] != "dark" { + t.Errorf("theme not preserved: %v", parsed["theme"]) + } + servers, _ := parsed["mcp"].(map[string]interface{}) + if _, ok := servers["other"]; !ok { + t.Error("existing mcp.other entry was lost") + } + if _, ok := servers["ctx"]; !ok { + t.Error("ctx server not added alongside existing entries") + } +} + +func TestEnsureMCPConfig_SkipsWhenCtxAlreadyRegistered(t *testing.T) { + chdirTemp(t) + + seed := []byte(`{"mcp":{"ctx":{"command":"custom"}}}`) + if err := os.WriteFile("opencode.json", seed, 0o644); err != nil { + t.Fatalf("seed: %v", err) + } + + var buf bytes.Buffer + if err := ensureMCPConfig(testCmd(&buf)); err != nil { + t.Fatalf("ensureMCPConfig: %v", err) + } + + got, _ := os.ReadFile("opencode.json") + if string(got) != string(seed) { + t.Errorf( + "file rewritten when ctx already registered: %s", got, + ) + } + if !bytes.Contains(buf.Bytes(), []byte("skipped")) { + t.Errorf( + "expected 'skipped' in output, got %q", buf.String(), + ) + } +} + +func TestEnsureMCPConfig_RejectsMalformedJSON(t *testing.T) { + chdirTemp(t) + + if err := os.WriteFile( + "opencode.json", []byte("{not json"), 0o644, + ); err != nil { + t.Fatalf("seed: %v", err) + } + + var buf bytes.Buffer + if err := ensureMCPConfig(testCmd(&buf)); err == nil { + t.Fatal("expected error on malformed JSON, got nil") + } + + // Verify we did not clobber the user's broken-but-extant file. + got, _ := os.ReadFile("opencode.json") + if !bytes.Contains(got, []byte("{not json")) { + t.Errorf("original malformed file overwritten: %s", got) + } +} diff --git a/internal/cli/setup/core/opencode/testmain_test.go b/internal/cli/setup/core/opencode/testmain_test.go new file mode 100644 index 000000000..e9d309dd8 --- /dev/null +++ b/internal/cli/setup/core/opencode/testmain_test.go @@ -0,0 +1,19 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "os" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} diff --git a/specs/opencode-integration.md b/specs/opencode-integration.md index 3a940335a..b191d7b82 100644 --- a/specs/opencode-integration.md +++ b/specs/opencode-integration.md @@ -26,9 +26,8 @@ scripts) — not a build dependency. All real logic stays in Go via `ctx system` ``` internal/assets/integrations/opencode/ ├── plugin/ -│ ├── index.ts # Thin shim plugin (~40 lines) +│ ├── index.ts # Thin shim plugin (~50 lines) │ └── package.json # Minimal: name, version, @opencode-ai/plugin dep -├── INSTRUCTIONS.md # OpenCode-specific agent instructions (adapt from copilot-cli) └── skills/ # ctx skills in OpenCode format ├── ctx-agent/SKILL.md ├── ctx-remember/SKILL.md @@ -36,43 +35,23 @@ internal/assets/integrations/opencode/ └── ctx-wrap-up/SKILL.md ``` -**`plugin/index.ts`** — the core deliverable: -```typescript -import type { Plugin } from "@opencode-ai/plugin" - -export default ((ctx) => ({ - "shell.env": () => ({ - CTX_DIR: ".context", - }), - event: { - "session.created": async () => { - await ctx.$`ctx system bootstrap 2>/dev/null || true` - await ctx.$`ctx agent --budget 4000 2>/dev/null || true` - }, - "session.idle": async () => { - await ctx.$`ctx system check-persistence 2>/dev/null || true` - await ctx.$`ctx system check-task-completion 2>/dev/null || true` - }, - }, - "tool.execute.before": async ({ tool, input }) => { - if (tool === "shell" || tool === "bash") { - const cmd = typeof input === "string" ? input : JSON.stringify(input) - const result = await ctx.$`echo ${cmd} | ctx system block-dangerous-commands --caller opencode 2>/dev/null` - if (result.exitCode !== 0) { - return { blocked: true, reason: result.stdout.toString().trim() } - } - } - }, - "tool.execute.after": async ({ tool }) => { - if (tool === "shell" || tool === "bash") { - await ctx.$`ctx system post-commit 2>/dev/null || true` - } - if (tool === "edit" || tool === "write" || tool === "file_edit") { - await ctx.$`ctx system check-task-completion 2>/dev/null || true` - } - }, -})) satisfies Plugin -``` +OpenCode reads `AGENTS.md` natively, so we deploy the shared +`agent.AgentsMd()` template at the project root rather than shipping +an OpenCode-specific instructions file. + +**`plugin/index.ts`** — the core deliverable. Wires `session.created` +and `session.idle` to `ctx system` nudges, runs `post-commit` after +shell commands that contain `git commit`, and runs +`check-task-completion` after edit/write tool calls. Tool name strings +target `@opencode-ai/plugin` v1.4.x; unrecognized tools silently +no-op. + +We deliberately do **not** ship a `tool.execute.before` hook here: +the natural fit (block-dangerous-commands) is currently a Claude Code +plugin-local hook, not a `ctx system` subcommand, so a shim that +shells out to it would block every shell command on installs that +don't have the wrapper. Add this back when block-dangerous-commands +is promoted to the ctx Go binary. **`plugin/package.json`**: ```json @@ -87,9 +66,6 @@ export default ((ctx) => ({ } ``` -**`INSTRUCTIONS.md`** — Adapt from `copilot-cli/INSTRUCTIONS.md`, replacing -Copilot CLI specifics with OpenCode equivalents. Same ctx session protocol. - **`skills/`** — Subset of portable skills (ctx-agent, ctx-remember, ctx-status, ctx-wrap-up). Format: YAML frontmatter + markdown body, same as Copilot CLI skills. @@ -101,9 +77,6 @@ Add functions (following `CopilotCLI*` pattern): // OpenCodePlugin reads the embedded OpenCode plugin directory. func OpenCodePlugin() (map[string][]byte, error) // filename -> content -// OpenCodeInstructions reads INSTRUCTIONS.md for OpenCode. -func OpenCodeInstructions() ([]byte, error) - // OpenCodeSkills reads embedded OpenCode skill templates. func OpenCodeSkills() (map[string][]byte, error) // skill name -> SKILL.md ``` @@ -188,9 +161,9 @@ write.hook-opencode-summary: short: |- OpenCode will now: 1. Bootstrap ctx context on session start - 2. Block dangerous commands (tool.execute.before) - 3. Nudge persistence on session idle - 4. Track task completion after edits + 2. Nudge persistence on session idle + 3. Track task completion after edits + 4. Run post-commit capture after `git commit` ``` ### 8. Setup Core Package (`internal/cli/setup/core/opencode/`) From 257fd6564e96506348683a84af5d77059b177ad7 Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Sun, 26 Apr 2026 15:45:09 -0400 Subject: [PATCH 03/30] context: capture OpenCode PR review session Persist learnings, decisions, conventions, and follow-up tasks from the PR #72 review and refinement pass. Learnings: - ctx system help can list project-local Claude wrappers that aren't real Go subcommands; non-Claude integrations only see the Go subset - Trailing \b in a regex matches commit-tree as git commit; need (?!-) - make test exit code unreliable due to -cover covdata tooling issue Decisions: - OpenCode plugin ships without tool.execute.before until block-dangerous-commands is a real ctx system Go subcommand - Editor plugins must filter post-commit to actual git commit calls Conventions: - New editor integrations include an MCP-merge test covering the five canonical edge cases Tasks (follow-up): - Promote block-dangerous-commands to a Go subcommand - Type-check embedded TS plugin assets in CI Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Omer Kocaoglu --- .context/CONVENTIONS.md | 2 ++ .context/DECISIONS.md | 32 +++++++++++++++++++++++++++++++- .context/LEARNINGS.md | 35 ++++++++++++++++++++++++++++++++++- .context/TASKS.md | 4 ++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/.context/CONVENTIONS.md b/.context/CONVENTIONS.md index adf3d8624..b2836c417 100644 --- a/.context/CONVENTIONS.md +++ b/.context/CONVENTIONS.md @@ -68,3 +68,5 @@ DO NOT UPDATE FOR: ``` - **Package doc in doc.go**: Each package gets a `doc.go` with package-level documentation - **Copyright headers**: All source files get the project copyright header + +- New editor integrations include an MCP-merge test covering: create / empty file / preserve existing keys / skip when registered / reject malformed JSON diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index 557f48513..3512b978b 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -2,7 +2,9 @@ | Date | Decision | -|----|--------| +|------|--------| +| 2026-04-26 | Editor-integration plugins must filter post-commit to actual git commit invocations | +| 2026-04-26 | OpenCode plugin ships without tool.execute.before hook | | 2026-04-25 | Use t.Setenv for subprocess env in tests, not append(os.Environ(), ...) | | 2026-04-25 | Tighten state.Dir / rc.ContextDir to (string, error) with sentinel errors | @@ -51,6 +53,34 @@ For significant decisions: ✗ No real alternatives existed --> +## [2026-04-26-152905] Editor-integration plugins must filter post-commit to actual git commit invocations + +**Status**: Accepted + +**Context**: Original PR #72 OpenCode plugin ran 'ctx system post-commit' after every shell tool call, not only after real commits + +**Decision**: Editor-integration plugins must filter post-commit to actual git commit invocations + +**Rationale**: post-commit is meaningful only after a real commit lands; firing on every shell call is noise that trains users to ignore the resulting nudges + +**Consequences**: Editor plugins always sniff the actual command string (regex on the extracted command) before triggering capture nudges that target specific commands. Same pattern applies to any future hook that targets a specific porcelain command. + +--- + +## [2026-04-26-152858] OpenCode plugin ships without tool.execute.before hook + +**Status**: Accepted + +**Context**: The natural fit (block-dangerous-commands) doesn't exist as a ctx system Go subcommand; shimming to it would block every shell call on installs without the Claude wrapper because Cobra's unknown-command exit 1 is read as { blocked: true } by OpenCode + +**Decision**: OpenCode plugin ships without tool.execute.before hook + +**Rationale**: Better to ship a feature-narrower plugin than one that bricks the editor for users without the wrapper. Re-add when block-dangerous-commands is promoted to the ctx Go binary. + +**Consequences**: OpenCode users get bootstrap, persistence, post-commit, and task-completion nudges but no dangerous-command safety net. specs/opencode-integration.md records the deliberate omission. + +--- + ## [2026-04-25-014704] Use t.Setenv for subprocess env in tests, not append(os.Environ(), ...) **Status**: Accepted diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index e967c8248..4a4baf668 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -16,13 +16,46 @@ DO NOT UPDATE FOR: | Date | Learning | -|----|--------| +|------|--------| +| 2026-04-26 | make test exit code unreliable due to -cover covdata tooling issue | +| 2026-04-26 | Trailing word boundary in regex matches commit-tree as git commit | +| 2026-04-26 | ctx system help can list project-local hooks not in the Go binary | | 2026-04-25 | Confident code comments can pull an LLM away from first-principles knowledge | | 2026-04-25 | filepath.Join('', rel) returns rel as CWD-relative, not error | | 2026-04-25 | Parallel go test ./... packages can race on ~/.claude/settings.json | +## [2026-04-26-152850] make test exit code unreliable due to -cover covdata tooling issue + +**Context**: make test exited 1 even with all 123 packages passing on this Go install; root cause is missing covdata tool when -cover is enabled + +**Lesson**: Don't trust make test exit code alone when verifying changes. The -cover flag in the test target can fail with 'no such tool covdata' even when every package passes. + +**Application**: When make test fails, fall back to 'go test ./...' (no -cover) and tally ^ok / ^FAIL counts to distinguish real failures from tooling issues. + +--- + +## [2026-04-26-152842] Trailing word boundary in regex matches commit-tree as git commit + +**Context**: First post-commit filter regex \bgit\s+commit\b in the OpenCode plugin would have triggered on git commit-tree because \b matches between t and - + +**Lesson**: A trailing word boundary doesn't exclude hyphenated continuations — \b matches every word/non-word transition. Use (?!-) negative lookahead to specifically reject hyphen-suffixed siblings. + +**Application**: For any porcelain with hyphenated cousins (commit-tree, commit-graph, for-each-ref), append (?!-) to the boundary. + +--- + +## [2026-04-26-152836] ctx system help can list project-local hooks not in the Go binary + +**Context**: PR #72 plugin called 'ctx system block-dangerous-commands'; user's installed ctx 0.7.2 listed it in help, but no directory exists under internal/cli/system/cmd/ — it's a Claude Code plugin-local hook surfaced via wrapper + +**Lesson**: ctx system help output is a union of compiled Go subcommands and project-local Claude wrappers; non-Claude integrations only see the Go subset + +**Application**: When porting plugin behavior to a new editor, only call subcommands that have a directory under internal/cli/system/cmd/. Don't trust ctx system help output as the canonical surface. + +--- + ## [2026-04-25-014704] Confident code comments can pull an LLM away from first-principles knowledge **Context**: cli_test.go had a comment claiming 'parent's t.Setenv doesn't propagate to exec'd children unless we build it into cmd.Env' which is wrong. I patched the helper's CTX_DIR dedup instead of questioning the helper itself, despite knowing t.Setenv semantics. diff --git a/.context/TASKS.md b/.context/TASKS.md index 7ffb93439..e63b5a811 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -26,6 +26,10 @@ TASK STATUS LABELS: --> ### Phase 1: [Name] `#priority:high` +- [ ] Add TypeScript type-check step (bunx tsc --noEmit) for embedded editor-plugin assets to CI; nothing currently checks .opencode/plugins/ctx/index.ts before embedding #priority:low #added:2026-04-26-152912 + +- [ ] Promote 'block-dangerous-commands' to a real ctx system Go subcommand so OpenCode and other non-Claude editor integrations can ship the safety hook #priority:medium #added:2026-04-26-152911 + - [ ] Task 1 - [ ] Task 2 From 6ee50fd092bde5b41aca3515dbd7e58156b7f026 Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Sun, 26 Apr 2026 16:57:08 -0400 Subject: [PATCH 04/30] fix(opencode): destructure args (not input) in tool.execute.after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin callback's first argument is `{tool, sessionID, callID, args}` per @opencode-ai/plugin v1.4.x. Destructuring `input` pulled a non-existent property, so the git-commit detection branch and the EDIT_TOOLS branch never had a real command to inspect — the post-commit and check-task-completion nudges silently no-op'd. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Omer Kocaoglu --- internal/assets/integrations/opencode/plugin/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/assets/integrations/opencode/plugin/index.ts b/internal/assets/integrations/opencode/plugin/index.ts index d7fc76184..3725c84b0 100644 --- a/internal/assets/integrations/opencode/plugin/index.ts +++ b/internal/assets/integrations/opencode/plugin/index.ts @@ -36,9 +36,9 @@ export default ((ctx) => ({ await ctx.$`ctx system check-task-completion 2>/dev/null || true` }, }, - "tool.execute.after": async ({ tool, input }) => { + "tool.execute.after": async ({ tool, args }) => { if (SHELL_TOOLS.has(tool)) { - const cmd = extractCommand(input) + const cmd = extractCommand(args) if (GIT_COMMIT_RE.test(cmd)) { await ctx.$`ctx system post-commit 2>/dev/null || true` } From 41881a06bdfef15658d984ee9e52f1787984a7fb Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Sun, 26 Apr 2026 18:00:53 -0400 Subject: [PATCH 05/30] fix(opencode): emit OpenCode-compatible MCP server shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode's McpLocalConfig schema (in @opencode-ai/sdk) requires `command` to be an Array holding both the binary and its arguments — there's no separate `args` field — and an `enabled` boolean on the entry. The generator was emitting the Copilot CLI shape (`command` as a string, `args` as a separate array), so opencode startup rejected the file with: Configuration is invalid at /…/opencode.json ↳ Expected array, got "ctx" mcp.ctx.command ↳ Missing key mcp.ctx.enabled Fold mcpServer.Command + Args() into a single command array, set enabled: true, and drop the args field for the OpenCode path. The Copilot CLI generator is unchanged — it still uses the {command, args} split that mcp-config.json expects. Add KeyEnabled constant; update the MCP regression test to assert the new shape (command as []string of length 3, no args field, enabled=true). Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Omer Kocaoglu --- internal/cli/setup/core/opencode/mcp.go | 9 ++++++--- internal/cli/setup/core/opencode/mcp_test.go | 20 +++++++++++++++++--- internal/config/hook/hook.go | 3 +++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/internal/cli/setup/core/opencode/mcp.go b/internal/cli/setup/core/opencode/mcp.go index 06a4ebe81..6243a2bbf 100644 --- a/internal/cli/setup/core/opencode/mcp.go +++ b/internal/cli/setup/core/opencode/mcp.go @@ -59,11 +59,14 @@ func ensureMCPConfig(cmd *cobra.Command) error { return nil } - // Add ctx MCP server. + // Add ctx MCP server. OpenCode's McpLocalConfig schema differs + // from Copilot CLI's: `command` is an Array that holds + // both the binary and its args (no separate `args` field), and + // `enabled` is required at runtime. servers[mcpServer.Name] = map[string]interface{}{ cfgHook.KeyType: cfgHook.MCPServerType, - cfgHook.KeyCommand: mcpServer.Command, - cfgHook.KeyArgs: mcpServer.Args(), + cfgHook.KeyCommand: append([]string{mcpServer.Command}, mcpServer.Args()...), + cfgHook.KeyEnabled: true, } existing[cfgHook.KeyMCP] = servers diff --git a/internal/cli/setup/core/opencode/mcp_test.go b/internal/cli/setup/core/opencode/mcp_test.go index 47148b3b8..61a907c92 100644 --- a/internal/cli/setup/core/opencode/mcp_test.go +++ b/internal/cli/setup/core/opencode/mcp_test.go @@ -61,12 +61,26 @@ func TestEnsureMCPConfig_CreatesFile(t *testing.T) { if !ok { t.Fatal("missing mcp.ctx key") } - if ctxServer["command"] != "ctx" { - t.Errorf("command = %q, want ctx", ctxServer["command"]) - } if ctxServer["type"] != "local" { t.Errorf("type = %q, want local", ctxServer["type"]) } + cmdArr, ok := ctxServer["command"].([]interface{}) + if !ok { + t.Fatalf("command must be an array per OpenCode schema, got %T", ctxServer["command"]) + } + if got := len(cmdArr); got != 3 { + t.Errorf("command length = %d, want 3 (binary + 2 args)", got) + } + if cmdArr[0] != "ctx" { + t.Errorf("command[0] = %q, want ctx", cmdArr[0]) + } + if _, hasArgs := ctxServer["args"]; hasArgs { + t.Error("args field must not be set; OpenCode schema folds args into command array") + } + enabled, ok := ctxServer["enabled"].(bool) + if !ok || !enabled { + t.Errorf("enabled = %v, want true", ctxServer["enabled"]) + } } func TestEnsureMCPConfig_TreatsEmptyFileAsAbsent(t *testing.T) { diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 7a9ca4655..0951dd6d0 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -104,6 +104,9 @@ const ( KeyCommand = "command" // KeyArgs is the JSON key for MCP server args. KeyArgs = "args" + // KeyEnabled is the JSON key for the MCP server enabled flag + // (used by OpenCode's McpLocalConfig schema). + KeyEnabled = "enabled" // KeyTools is the JSON key for MCP server tools filter. KeyTools = "tools" // ToolsWildcard is the wildcard value for MCP tools access. From 51a6f0734326c13fd8a83d1c56ec8299b6e31ab4 Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Sun, 26 Apr 2026 20:26:27 -0400 Subject: [PATCH 06/30] fix(opencode): deploy plugin as flat .opencode/plugins/ctx.ts (not a subdirectory) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode auto-loads only top-level .ts/.js files under .opencode/plugins/; subdirectories are silently ignored. The v0.7.x setup deployed the plugin to .opencode/plugins/ctx/index.ts, so the entire OpenCode integration shipped in PR #72 — the session/idle hooks, the post-commit nudge, the check-task -completion nudge — was never actually loaded by OpenCode. The file was correct; OpenCode's discovery rule made it dead code. Verified by smoke-testing both layouts side-by-side: .opencode/plugins/ctx/index.ts produced no trace events even with --print-logs --log-level DEBUG. .opencode/plugins/ctx.ts loaded immediately, factory-call invoked, tool.execute.after fired with the expected args shape. Changes: - internal/cli/setup/core/opencode/plugin.go now writes the embedded index.ts content to .opencode/plugins/ctx.ts (flat). - New cfgHook.FileOpenCodePluginDeploy = "ctx.ts" constant. cfgHook.FileIndexTs is kept as the embedded-asset key (the source-of-truth filename in the binary) and its docstring now spells out the flat-vs-subdir discovery rule for future maintainers. - Drop internal/assets/integrations/opencode/plugin/package.json and its //go:embed directive: the plugin uses a type-only import of @opencode-ai/plugin (erased at compile time) and the host runtime injects PluginInput, so there is no runtime dependency tree to install. - New errSetup.MissingEmbeddedAsset() helper with a matching text key, so the new asset lookup uses the err package rather than a naked fmt.Errorf (audit fix). - specs/opencode-integration.md updated to describe the flat layout and a smoke-step that verifies a hook actually fires. - LEARNINGS.md captures the discovery so future plugins for any editor verify load before debugging hook contracts. Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Omer Kocaoglu --- .context/LEARNINGS.md | 22 +++++++ internal/assets/commands/text/errors.yaml | 2 + internal/assets/embed.go | 1 - .../integrations/opencode/plugin/package.json | 9 --- internal/cli/setup/core/opencode/plugin.go | 41 +++++++----- internal/config/embed/text/err_setup.go | 3 + internal/config/hook/hook.go | 12 +++- internal/err/setup/setup.go | 15 +++++ specs/opencode-integration.md | 64 ++++++++++--------- 9 files changed, 112 insertions(+), 57 deletions(-) delete mode 100644 internal/assets/integrations/opencode/plugin/package.json diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index 4a4baf668..0819ca889 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -17,6 +17,8 @@ DO NOT UPDATE FOR: | Date | Learning | |------|--------| +| 2026-04-26 | OpenCode auto-loads only flat .ts files under .opencode/plugins/; subdirectories are ignored | +| 2026-04-26 | OpenCode opencode.json MCP shape: command is Array, no separate args field | | 2026-04-26 | make test exit code unreliable due to -cover covdata tooling issue | | 2026-04-26 | Trailing word boundary in regex matches commit-tree as git commit | | 2026-04-26 | ctx system help can list project-local hooks not in the Go binary | @@ -26,6 +28,26 @@ DO NOT UPDATE FOR: +## [2026-04-26-180000] OpenCode auto-loads only flat .ts files under .opencode/plugins/; subdirectories are ignored + +**Context**: Initial OpenCode integration deployed the plugin as `.opencode/plugins/ctx/index.ts` (a directory with index.ts inside, mirroring npm package conventions). End-to-end smoke testing showed the plugin file was present and the binary was current, yet OpenCode never invoked any of the plugin's hooks (no `module-load` trace fired even with `--print-logs --log-level DEBUG`). Copying the same content to a flat `.opencode/plugins/ctx.ts` file made the plugin load and fire correctly. + +**Lesson**: OpenCode's plugin auto-discovery only scans top-level files under `.opencode/plugins/` and `~/.config/opencode/plugins/`. Subdirectories are silently skipped — there is no log line indicating a subdirectory was found and ignored. The official docs at opencode.ai/docs/plugins/ say only "files in these directories are automatically loaded at startup" without specifying the rule, so this is easy to miss. The `opencode plugin ` CLI registers npm modules (a different code path) and accepts only npm names, not local paths. + +**Application**: Deploy single-file plugins as `.opencode/plugins/.ts`, not `.opencode/plugins//index.ts`. No `package.json` is required when the plugin uses type-only imports (`import type` is erased at compile time) and the host runtime injects the plugin context. To verify a plugin is actually loaded, add a top-of-module side effect (e.g. `appendFileSync` to a known path) and confirm it fires before debugging hook contracts. + +--- + +## [2026-04-26-165500] OpenCode opencode.json MCP shape: command is Array, no separate args field + +**Context**: `ctx setup opencode --write` was generating `opencode.json` with the Copilot CLI MCP shape (`{type: "local", command: "ctx", args: ["mcp", "serve"]}`). OpenCode rejected the file at startup with `Configuration is invalid… Expected array, got "ctx" mcp.ctx.command` and `Missing key mcp.ctx.enabled`. + +**Lesson**: OpenCode's `McpLocalConfig` (in `@opencode-ai/sdk`) defines `command: Array` as a single field that holds the binary AND its arguments — there is no separate `args` field. It also requires `enabled: boolean` at runtime even though the TS type marks it optional. The Copilot CLI MCP shape is similar in spirit but structurally different; do not copy-paste between them. + +**Application**: For OpenCode MCP entries always use `command: ["ctx", "mcp", "serve"]` and include `enabled: true`. If you add a new editor integration with its own MCP file format, read the upstream type definitions from `node_modules/@/sdk/dist/gen/types.gen.d.ts` (or equivalent) before reusing an existing generator. + +--- + ## [2026-04-26-152850] make test exit code unreliable due to -cover covdata tooling issue **Context**: make test exited 1 even with all 123 packages passing on this Go install; root cause is missing covdata tool when -cover is enabled diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 6744e7247..39c141aef 100644 --- a/internal/assets/commands/text/errors.yaml +++ b/internal/assets/commands/text/errors.yaml @@ -441,6 +441,8 @@ err.setup.marshal-config: short: 'marshal mcp config: %w' err.setup.write-file: short: 'write %s: %w' +err.setup.missing-embedded-asset: + short: 'embedded asset missing: %s' err.setup.sync-steering: short: 'sync steering: %w' err.skill.create-dest: diff --git a/internal/assets/embed.go b/internal/assets/embed.go index f1d9a44b9..6076bce51 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -19,7 +19,6 @@ import ( //go:embed integrations/copilot-cli/scripts/*.ps1 //go:embed integrations/copilot-cli/skills/*/SKILL.md //go:embed integrations/opencode/plugin/index.ts -//go:embed integrations/opencode/plugin/package.json //go:embed integrations/opencode/skills/*/SKILL.md //go:embed hooks/messages/*/*.txt hooks/messages/registry.yaml hooks/trace/*.sh //go:embed schema/*.json why/*.md diff --git a/internal/assets/integrations/opencode/plugin/package.json b/internal/assets/integrations/opencode/plugin/package.json deleted file mode 100644 index 1121def32..000000000 --- a/internal/assets/integrations/opencode/plugin/package.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "name": "ctx-opencode-plugin", - "version": "0.1.0", - "type": "module", - "main": "index.ts", - "dependencies": { - "@opencode-ai/plugin": "^1.4.0" - } -} diff --git a/internal/cli/setup/core/opencode/plugin.go b/internal/cli/setup/core/opencode/plugin.go index d66a809c0..53dfed518 100644 --- a/internal/cli/setup/core/opencode/plugin.go +++ b/internal/cli/setup/core/opencode/plugin.go @@ -15,16 +15,24 @@ import ( "github.com/ActiveMemory/ctx/internal/assets/read/agent" "github.com/ActiveMemory/ctx/internal/config/fs" cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" - mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" errFs "github.com/ActiveMemory/ctx/internal/err/fs" + errSetup "github.com/ActiveMemory/ctx/internal/err/setup" ctxIo "github.com/ActiveMemory/ctx/internal/io" writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) -// deployPlugin creates .opencode/plugins/ctx/ with the embedded -// plugin files (index.ts and package.json). Skips if index.ts +// deployPlugin writes the embedded plugin to +// .opencode/plugins/ctx.ts. OpenCode auto-loads top-level files +// under .opencode/plugins/; subdirectories are not scanned, so a +// flat single-file deployment is required. Skips if the target // already exists. // +// The package.json that v0.8.x and earlier shipped alongside +// index.ts is no longer embedded: the plugin uses a type-only +// import of @opencode-ai/plugin (erased at compile time) and the +// host runtime provides the plugin context, so no runtime +// dependency tree is needed. +// // Parameters: // - cmd: Cobra command for output messages // @@ -34,14 +42,12 @@ func deployPlugin(cmd *cobra.Command) error { pluginDir := filepath.Join( cfgHook.DirOpenCode, cfgHook.DirOpenCodePlugins, - mcpServer.Name, ) - - indexPath := filepath.Join( - pluginDir, cfgHook.FileIndexTs, + target := filepath.Join( + pluginDir, cfgHook.FileOpenCodePluginDeploy, ) - if _, statErr := os.Stat(indexPath); statErr == nil { - writeSetup.InfoOpenCodeSkipped(cmd, pluginDir) + if _, statErr := os.Stat(target); statErr == nil { + writeSetup.InfoOpenCodeSkipped(cmd, target) return nil } @@ -55,16 +61,17 @@ func deployPlugin(cmd *cobra.Command) error { if readErr != nil { return readErr } + content, ok := files[cfgHook.FileIndexTs] + if !ok { + return errSetup.MissingEmbeddedAsset(cfgHook.FileIndexTs) + } - for name, content := range files { - target := filepath.Join(pluginDir, name) - if wErr := ctxIo.SafeWriteFile( - target, content, fs.PermFile, - ); wErr != nil { - return errFs.FileWrite(target, wErr) - } - writeSetup.InfoOpenCodeCreated(cmd, target) + if wErr := ctxIo.SafeWriteFile( + target, content, fs.PermFile, + ); wErr != nil { + return errFs.FileWrite(target, wErr) } + writeSetup.InfoOpenCodeCreated(cmd, target) return nil } diff --git a/internal/config/embed/text/err_setup.go b/internal/config/embed/text/err_setup.go index b26b7b798..592c68b59 100644 --- a/internal/config/embed/text/err_setup.go +++ b/internal/config/embed/text/err_setup.go @@ -18,4 +18,7 @@ const ( // DescKeyErrSetupSyncSteering is the text key for err setup sync steering // messages. DescKeyErrSetupSyncSteering = "err.setup.sync-steering" + // DescKeyErrSetupMissingEmbeddedAsset is the text key for the + // "embedded asset missing" setup error. + DescKeyErrSetupMissingEmbeddedAsset = "err.setup.missing-embedded-asset" ) diff --git a/internal/config/hook/hook.go b/internal/config/hook/hook.go index 0951dd6d0..c123aaf50 100644 --- a/internal/config/hook/hook.go +++ b/internal/config/hook/hook.go @@ -125,8 +125,18 @@ const ( FileOpenCodeJSON = "opencode.json" // KeyMCP is the top-level JSON key for MCP in opencode.json. KeyMCP = "mcp" - // FileIndexTs is the OpenCode plugin entry point file. + // FileIndexTs is the embedded-asset filename for the OpenCode + // plugin source. The setup deploys this content to a flat file + // under [DirOpenCodePlugins], NOT preserving the index.ts name — + // OpenCode only auto-loads top-level files in .opencode/plugins/, + // so subdirectory layouts (.opencode/plugins//index.ts) + // are silently ignored. FileIndexTs = "index.ts" + // FileOpenCodePluginDeploy is the deployment filename for the + // OpenCode plugin under .opencode/plugins/. Must be a flat + // .ts/.js file directly under the plugins directory; see + // FileIndexTs for the auto-load discovery rule. + FileOpenCodePluginDeploy = "ctx.ts" ) // Prefixes diff --git a/internal/err/setup/setup.go b/internal/err/setup/setup.go index 5a270643a..c1e51c51b 100644 --- a/internal/err/setup/setup.go +++ b/internal/err/setup/setup.go @@ -66,3 +66,18 @@ func SyncSteering(cause error) error { desc.Text(text.DescKeyErrSetupSyncSteering), cause, ) } + +// MissingEmbeddedAsset reports that an asset expected to be +// embedded in the binary is missing — typically a setup-time +// invariant violation rather than a user-facing failure. +// +// Parameters: +// - name: the asset key that was looked up +// +// Returns: +// - error: "embedded asset missing: " +func MissingEmbeddedAsset(name string) error { + return fmt.Errorf( + desc.Text(text.DescKeyErrSetupMissingEmbeddedAsset), name, + ) +} diff --git a/specs/opencode-integration.md b/specs/opencode-integration.md index b191d7b82..a4dbc2eca 100644 --- a/specs/opencode-integration.md +++ b/specs/opencode-integration.md @@ -26,8 +26,7 @@ scripts) — not a build dependency. All real logic stays in Go via `ctx system` ``` internal/assets/integrations/opencode/ ├── plugin/ -│ ├── index.ts # Thin shim plugin (~50 lines) -│ └── package.json # Minimal: name, version, @opencode-ai/plugin dep +│ └── index.ts # Thin shim plugin (~80 lines) └── skills/ # ctx skills in OpenCode format ├── ctx-agent/SKILL.md ├── ctx-remember/SKILL.md @@ -53,18 +52,13 @@ shells out to it would block every shell command on installs that don't have the wrapper. Add this back when block-dangerous-commands is promoted to the ctx Go binary. -**`plugin/package.json`**: -```json -{ - "name": "ctx-opencode-plugin", - "version": "0.1.0", - "type": "module", - "main": "index.ts", - "dependencies": { - "@opencode-ai/plugin": "^1.4.0" - } -} -``` +**Deployment layout**: OpenCode auto-loads top-level `.ts`/`.js` +files under `.opencode/plugins/`; subdirectories are NOT scanned. +The setup deploys a single flat file at `.opencode/plugins/ctx.ts`. +No `package.json` is needed — the plugin uses a type-only import +of `@opencode-ai/plugin` (erased at compile time) and the host +runtime provides the plugin context, so there's no runtime +dependency tree to install. **`skills/`** — Subset of portable skills (ctx-agent, ctx-remember, ctx-status, ctx-wrap-up). Format: YAML frontmatter + markdown body, same as Copilot CLI skills. @@ -112,7 +106,7 @@ Add: ```go DisplayOpenCode = "OpenCode" MCPConfigPathOpenCode = "opencode.json" -PluginPathOpenCode = ".opencode/plugins/ctx/" +PluginPathOpenCode = ".opencode/plugins/ctx.ts" SkillsPathOpenCode = ".opencode/skills/" ``` @@ -135,12 +129,11 @@ hook.opencode: OpenCode Integration ==================== - Generate .opencode/plugins/ctx/ with ctx lifecycle hooks + Generate .opencode/plugins/ctx.ts with ctx lifecycle hooks and register the ctx MCP server in opencode.json. This creates: - .opencode/plugins/ctx/index.ts Plugin shim - .opencode/plugins/ctx/package.json Dependencies + .opencode/plugins/ctx.ts Plugin shim .opencode/skills/ctx-*/SKILL.md ctx skills opencode.json MCP server registration @@ -172,7 +165,7 @@ write.hook-opencode-summary: internal/cli/setup/core/opencode/ ├── doc.go # Package documentation ├── opencode.go # Deploy() entry point -├── plugin.go # deployPlugin() — writes .opencode/plugins/ctx/ +├── plugin.go # deployPlugin() — writes .opencode/plugins/ctx.ts ├── mcp.go # ensureMCPConfig() — merges opencode.json ├── skill.go # deploySkills() — writes .opencode/skills/ └── agents.go # deployAgents() — writes AGENTS.md (shared template) @@ -181,7 +174,7 @@ internal/cli/setup/core/opencode/ **`opencode.go` — Deploy()**: ```go func Deploy(cmd *cobra.Command) error { - // 1. Deploy plugin files (.opencode/plugins/ctx/) + // 1. Deploy the plugin file (.opencode/plugins/ctx.ts) if pluginErr := deployPlugin(cmd); pluginErr != nil { return pluginErr } @@ -204,14 +197,17 @@ func Deploy(cmd *cobra.Command) error { **`mcp.go` — ensureMCPConfig()**: -OpenCode MCP config lives in `opencode.json` at project root: +OpenCode MCP config lives in `opencode.json` at project root. Per +the `@opencode-ai/sdk` `McpLocalConfig` schema, `command` is a +single string array holding the binary and its arguments (no +separate `args` field) and `enabled` is required: ```json { "mcp": { "ctx": { "type": "local", - "command": "ctx", - "args": ["mcp", "serve"] + "command": ["ctx", "mcp", "serve"], + "enabled": true } } } @@ -222,9 +218,14 @@ entry, write back. Preserve all other config keys. **`plugin.go` — deployPlugin()**: -Extract embedded `index.ts` and `package.json` to `.opencode/plugins/ctx/`. -Skip if `index.ts` already exists (idempotent). OpenCode auto-runs -`bun install` in plugin directories at startup. +Write the embedded `index.ts` content to a flat +`.opencode/plugins/ctx.ts` file. Skip if the target already exists +(idempotent). OpenCode only auto-loads top-level files under +`.opencode/plugins/`; subdirectories are NOT scanned, which is why +the deployment is a single flat file rather than a directory. +No `package.json` is shipped — the plugin uses a type-only import +of `@opencode-ai/plugin` and the host runtime provides the plugin +context, so there's no runtime dependency tree to install. ### 9. Write Setup Functions (`internal/write/setup/hook.go`) @@ -296,11 +297,16 @@ case cfgHook.ToolOpenCode: 1. **Build**: `make build` — verify compilation with new package 2. **Dry run**: `ctx setup opencode` — should print integration instructions 3. **Write**: `ctx setup opencode --write` in a test project — verify: - - `.opencode/plugins/ctx/index.ts` created - - `.opencode/plugins/ctx/package.json` created - - `opencode.json` has `mcp.ctx` entry (merged, not overwritten) + - `.opencode/plugins/ctx.ts` created (flat file; no subdirectory) + - `opencode.json` has `mcp.ctx` entry (merged, not overwritten), + with `command` as a string array and `enabled: true` - `AGENTS.md` created (or skipped if exists with markers) - `.opencode/skills/ctx-*/SKILL.md` created + - Confirm OpenCode actually loads the plugin: launch + `opencode --print-logs --log-level DEBUG` in the test project, + ask the agent to make an edit and run `git commit`, and verify + the plugin's `tool.execute.after` fires the `ctx system + post-commit` and `ctx system check-task-completion` nudges 4. **Idempotency**: Run `ctx setup opencode --write` twice — second run skips existing 5. **Lint**: `make lint` 6. **Test**: `make test` From dc72898fcf8142d7cf706b3c1da0c66265600b81 Mon Sep 17 00:00:00 2001 From: Omer Kocaoglu Date: Mon, 27 Apr 2026 00:19:49 -0400 Subject: [PATCH 07/30] fix(opencode): wrap MCP launch in sh -c so CTX_DIR resolves to $PWD/.context MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP server registered by 'ctx setup opencode --write' failed to hand-shake from OpenCode. Three failure modes, one root cause: ctx requires CTX_DIR to be absolute (internal/rc.ContextDir's "absolute-only hardline"), and OpenCode has no path templating in opencode.json — neither environment.CTX_DIR=".context" nor a literal absolute path that follows the user's checkout works. Without an explicit pin, OpenCode forwards the parent shell's CTX_DIR. A stale value (anchor drift) gives 'context directory not found'; an unset value with overlapping .context candidates gives 'multiple candidates visible'. Both kill the JSON-RPC handshake before any tool can register, leaving 'ctx ✗ failed MCP error -32000: Connection closed' in 'opencode mcp list'. Verified: OpenCode launches MCP children with project root as CWD and forwards parent env (incl. user CTX_DIR). Both confirmed empirically with a debug shim that logged argv/cwd/env from inside an opencode mcp list invocation. Fix: emit ['sh', '-c', 'exec env CTX_DIR="$PWD/.context" ctx mcp serve']. $PWD is set by sh to the project root OpenCode chose, giving us an absolute path anchored to whichever checkout owns this opencode.json. exec replaces the shell so OpenCode's process tree has ctx directly, no lingering sh layer. Verified end-to-end: 'opencode mcp list' shows '✓ ctx connected' and a manual initialize+tools/list handshake against the same launcher returns the 15 ctx tools. Changes: - internal/cli/setup/core/opencode/mcp.go: emit the sh wrapper via a new launchCommand() helper; drop the broken environment.CTX_DIR field; comment captures the rejection reasoning so a future maintainer doesn't reintroduce the relative-path attempt. - internal/cli/setup/core/opencode/mcp_test.go: assert the new shape — sh/-c prefix, script substrings (exec env, the quoted $PWD/.context expansion, the wrapped invocation), and an explicit assertion that 'environment' must NOT be present (the failure mode this commit fixes). - internal/config/shell/shell.go: new CmdFlag ('-c') and FormatPOSIXSpawnRelativeCtxDir constants, keeping the inline-script template out of call sites per the magic-string audit. Spec: specs/opencode-integration.md Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Omer Kocaoglu --- internal/cli/setup/core/opencode/mcp.go | 39 +++++++++++++++++++- internal/cli/setup/core/opencode/mcp_test.go | 29 +++++++++++++-- internal/config/shell/shell.go | 14 +++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/internal/cli/setup/core/opencode/mcp.go b/internal/cli/setup/core/opencode/mcp.go index 6243a2bbf..2c53e4462 100644 --- a/internal/cli/setup/core/opencode/mcp.go +++ b/internal/cli/setup/core/opencode/mcp.go @@ -9,17 +9,44 @@ package opencode import ( "bytes" "encoding/json" + "fmt" + "strings" "github.com/spf13/cobra" + cfgDir "github.com/ActiveMemory/ctx/internal/config/dir" + "github.com/ActiveMemory/ctx/internal/config/env" "github.com/ActiveMemory/ctx/internal/config/fs" cfgHook "github.com/ActiveMemory/ctx/internal/config/hook" mcpServer "github.com/ActiveMemory/ctx/internal/config/mcp/server" + cfgShell "github.com/ActiveMemory/ctx/internal/config/shell" "github.com/ActiveMemory/ctx/internal/config/token" ctxIo "github.com/ActiveMemory/ctx/internal/io" writeSetup "github.com/ActiveMemory/ctx/internal/write/setup" ) +// launchCommand returns the OpenCode `command` array for the ctx +// MCP server. The emitted argv is: +// +// ["sh", "-c", `exec env CTX_DIR="$PWD/.context" ctx mcp serve`] +// +// `$PWD` is set by sh to the CWD OpenCode chose when spawning the +// MCP child — the project root that owns opencode.json. `exec` +// replaces the shell so the MCP child becomes ctx itself, with no +// sh process layered between OpenCode and the JSON-RPC stream. +// +// Returns: +// - []string: argv suitable for OpenCode's McpLocalConfig.command. +func launchCommand() []string { + binAndArgs := append([]string{mcpServer.Command}, mcpServer.Args()...) + script := fmt.Sprintf( + cfgShell.FormatPOSIXSpawnRelativeCtxDir, + env.CtxDir, cfgDir.Context, + strings.Join(binAndArgs, token.Space), + ) + return []string{cfgShell.Sh, cfgShell.CmdFlag, script} +} + // ensureMCPConfig registers the ctx MCP server in opencode.json // at the project root. // @@ -63,9 +90,19 @@ func ensureMCPConfig(cmd *cobra.Command) error { // from Copilot CLI's: `command` is an Array that holds // both the binary and its args (no separate `args` field), and // `enabled` is required at runtime. + // + // We wrap the launch in `sh -c` and resolve CTX_DIR to + // `$PWD/.context` at spawn time. ctx requires CTX_DIR to be + // absolute (see internal/rc.ContextDir), so a literal ".context" + // in the `environment` map is rejected before the JSON-RPC + // handshake. OpenCode also has no path templating in + // opencode.json, so we cannot embed an absolute path that + // follows the user's checkout. Computing it from $PWD at launch + // gives us an absolute path anchored to the project that owns + // this opencode.json, regardless of the user's shell CTX_DIR. servers[mcpServer.Name] = map[string]interface{}{ cfgHook.KeyType: cfgHook.MCPServerType, - cfgHook.KeyCommand: append([]string{mcpServer.Command}, mcpServer.Args()...), + cfgHook.KeyCommand: launchCommand(), cfgHook.KeyEnabled: true, } existing[cfgHook.KeyMCP] = servers diff --git a/internal/cli/setup/core/opencode/mcp_test.go b/internal/cli/setup/core/opencode/mcp_test.go index 61a907c92..47a92568d 100644 --- a/internal/cli/setup/core/opencode/mcp_test.go +++ b/internal/cli/setup/core/opencode/mcp_test.go @@ -10,6 +10,7 @@ import ( "bytes" "encoding/json" "os" + "strings" "testing" "github.com/spf13/cobra" @@ -68,15 +69,37 @@ func TestEnsureMCPConfig_CreatesFile(t *testing.T) { if !ok { t.Fatalf("command must be an array per OpenCode schema, got %T", ctxServer["command"]) } + // We wrap the launch in `sh -c` so $PWD can be substituted into + // CTX_DIR at MCP spawn time. ctx rejects relative CTX_DIR values + // (see internal/rc.ContextDir), and OpenCode has no path templating + // in opencode.json — the shell wrapper is how we get an absolute + // path that follows the user's checkout. if got := len(cmdArr); got != 3 { - t.Errorf("command length = %d, want 3 (binary + 2 args)", got) + t.Fatalf("command length = %d, want 3 (sh -c