diff --git a/.context/CONVENTIONS.md b/.context/CONVENTIONS.md index de39c8fea..a7e1f57cd 100644 --- a/.context/CONVENTIONS.md +++ b/.context/CONVENTIONS.md @@ -177,6 +177,8 @@ DO NOT UPDATE FOR: added, renamed, or removed, and the filesystem is self-documenting - **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 + ## Blog Publishing - **Checklist for ideas/ → docs/blog/ promotion**: diff --git a/.context/DECISIONS.md b/.context/DECISIONS.md index 7a09107c5..739b9160d 100644 --- a/.context/DECISIONS.md +++ b/.context/DECISIONS.md @@ -2,7 +2,10 @@ | Date | Decision | -|----|--------| +|------|--------| +| 2026-04-26 | OpenCode tool.execute.before omission is permanent; block-dangerous-commands will not become a ctx Go subcommand | +| 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-16 | Deprecate and remove ctx backup | | 2026-04-14 | doc.go quality floor: behavior-grounded, ~25-100 body lines, related-packages section required | | 2026-04-14 | Bootstrap stays under ctx system bootstrap (reverted experimental top-level promotion) | @@ -127,6 +130,47 @@ For significant decisions: ✗ No real alternatives existed --> +## [2026-04-26-231517] OpenCode tool.execute.before omission is permanent; block-dangerous-commands will not become a ctx Go subcommand + +**Status**: Accepted + +**Context**: The 2026-04-26-152858 decision shipped the OpenCode plugin without a tool.execute.before hook and noted "Re-add when block-dangerous-commands is promoted to the ctx Go binary." Revisited: that promotion is no longer planned. Keeping the open task on the books makes future sessions believe a re-add is pending. + +**Decision**: We will not promote block-dangerous-commands to a ctx system Go subcommand. The OpenCode plugin's missing tool.execute.before hook is permanent, not deferred. + +**Rationale**: The Cobra exit-1 / `{ blocked: true }` interaction makes any shim hostile to users without the Claude wrapper, and the safety-hook gap is acceptable given OpenCode's positioning. Recording this avoids the tax of a perpetually-pending follow-up that no one intends to land. + +**Consequences**: TASKS.md item "Promote 'block-dangerous-commands' to a real ctx system Go subcommand…" marked `[-]` skipped. The 2026-04-26-152858 rationale's "Re-add when…" clause is void; the underlying ship-without-the-hook decision remains in force. Other (non-OpenCode) editor integrations that want a dangerous-command safety net will need a different mechanism. + +**Related**: Amends [2026-04-26-152858] OpenCode plugin ships without tool.execute.before hook (rationale's deferred re-add is now closed). + +--- + +## [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-16-011520] Deprecate and remove ctx backup diff --git a/.context/LEARNINGS.md b/.context/LEARNINGS.md index cacda61f2..276ee98b6 100644 --- a/.context/LEARNINGS.md +++ b/.context/LEARNINGS.md @@ -16,7 +16,17 @@ DO NOT UPDATE FOR: | Date | Learning | -|----|--------| +|------|--------| +| 2026-04-29 | BunShell ctx.$ calls echo stdout to OpenCode's process unless .quiet() is set — leaks visible noise | +| 2026-04-29 | OpenCode plugin compaction interop is breadcrumb-mediated: own your context preservation explicitly | +| 2026-04-29 | @opencode-ai/plugin event hook is a single dispatcher, not an object of named handlers | +| 2026-04-29 | OpenCode plugin hooks like shell.env take (input, output) and mutate; returned objects are ignored | +| 2026-04-29 | OpenCode shell.env injects env only into agent's shell tool, not into plugin's own ctx.$ calls | +| 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 | | 2026-04-14 | Constitution forbids context window as a deferral excuse | | 2026-04-14 | docs/cli/system.md and embed/cmd/system.go diverged on bootstrap promotion intent | | 2026-04-14 | Raft-lite trade-off is the load-bearing choice in internal/hub | @@ -121,6 +131,104 @@ DO NOT UPDATE FOR: | 2026-04-25 | Parallel go test ./... packages can race on ~/.claude/settings.json | + +## [2026-04-29-050000] BunShell ctx.$ calls echo stdout to OpenCode's process unless .quiet() is set — leaks visible noise + +**Context**: After PR #72 wired session.created and session.idle to fire `ctx system bootstrap`, `ctx agent --budget 4000`, and friends, end users started seeing chunks of Markdown bleeding into the OpenCode TUI: `## Steering`, `# Product Context`, `Describe the product...`. These are the contents of `.context/steering/` template stubs that `ctx agent --budget 4000` includes in its context packet. The plugin used the shell-level `2>/dev/null || true` to swallow stderr and force exit 0, but stdout was untouched. + +**Lesson**: BunShell's documented behavior: *"By default, the shell will write to the current process's stdout and stderr, as well as buffering that output."* So an `await ctx.$\`...\`` call in a plugin echoes its stdout/stderr to OpenCode's process, which the TUI/agent surfaces. Shell-level `2>/dev/null` only suppresses stderr; stdout still leaks. The fix is BunShell's `.quiet()` modifier on the BunShellPromise, which configures the shell to only buffer the output rather than also writing to the parent process. + +**Application**: Always chain `.nothrow().quiet()` on BunShell template literals in OpenCode plugins, even for fire-and-forget calls where you discard the result: `await ctx.$\`ctx system bootstrap\`.nothrow().quiet()`. With both modifiers, you don't need shell-level `2>/dev/null || true` — `.nothrow()` swallows non-zero exits at the BunShell layer, `.quiet()` keeps every byte of output buffered. Pattern is the cooperative default for any plugin that spawns long-output commands during the agent session lifecycle. + +--- + +## [2026-04-29-040000] OpenCode plugin compaction interop is breadcrumb-mediated: own your context preservation explicitly + +**Context**: After PR #72 wired `session.created` / `session.idle` / `tool.execute.after` / `shell.env`, a `/compact` test in OpenCode (with `oh-my-openagent@3.17.6` also installed) recovered ctx context post-compaction *only by accident*: oh-my-openagent's `experimental.session.compacting` handler builds a structured summary template that happens to preserve `.context/`-prefixed file paths in its "Active Working Context → Files" section. Combined with our `shell.env` CTX_DIR injection, the agent had enough breadcrumbs to re-read DECISIONS.md from disk post-compaction. Without that section, our context would have evaporated silently into the compaction summary. + +**Lesson**: Two compaction-aware plugins in the same session can synergize without either knowing about the other — but the synergy is fragile because it depends on undocumented serialization choices in the *other* plugin. If the other plugin's template ever changes (e.g., drops file-path preservation, swaps the "Active Working Context" section name, condenses paths to basenames), the breadcrumbs disappear and ctx context is lost without any signal. The `Hooks` interface in `@opencode-ai/plugin` v1.4.x exposes `experimental.session.compacting?: (input, output: { context: string[]; prompt?: string }) => Promise` — pushing to `output.context` is *additive* (appends to the default prompt), and replacing `output.prompt` is *destructive* (only one plugin can win that race). + +**Application**: Register `experimental.session.compacting` in your own plugin and push high-signal context strings (e.g., `ctx system bootstrap` output) to `output.context` so context preservation does not depend on coexisting plugins. Never set `output.prompt` from a thin shim — that would conflict with primary compaction harnesses like oh-my-openagent. Composition via `output.context` is the correct cooperative pattern. + +--- + +## [2026-04-29-030000] @opencode-ai/plugin event hook is a single dispatcher, not an object of named handlers + +**Context**: PR #72's first OpenCode plugin shipped with `event: { "session.created": fn, "session.idle": fn }` — an object keyed by event type. It compiled clean against `satisfies Plugin` but never fired. End-to-end trace showed neighboring hooks (`shell.env`, `tool.execute.after`) running while every event handler silently no-op'd. + +**Lesson**: `@opencode-ai/plugin` v1.4.x defines `event?: (input: { event: Event }) => Promise` — one dispatcher called for every event with `input.event.type` discriminating. Asymmetric with neighbors because `shell.env` and `tool.execute.*` *are* top-level named keys; only the dozens of `EventX` types collapse into the single `event` slot. + +**Application**: Use `event: async ({event}) => { if (event.type === "session.created") { ... } else if (event.type === "session.idle") { ... } }`. Type discriminator strings live under each `EventX` type in `node_modules/@opencode-ai/sdk/dist/gen/types.gen.d.ts`. + +--- + +## [2026-04-29-030100] OpenCode plugin hooks like shell.env take (input, output) and mutate; returned objects are ignored + +**Context**: First plugin had `"shell.env": () => ({ CTX_DIR: ".context" })`. The hook fired but the agent's bash tool never saw `CTX_DIR`; manual export was required for every ctx call. The returned object was dropped on the floor by the runtime. + +**Lesson**: Multiple hooks in `@opencode-ai/plugin` v1.4.x take two arguments where the second is an OUT param. Examples: `shell.env: (input, output: {env}) => void` (mutate `output.env`), `tool.execute.after: (input, output: {title, output, metadata}) => void`, `chat.params: (input, output: {temperature, ...}) => void`, `chat.headers: (input, output: {headers}) => void`. Pattern is consistent across the SDK. + +**Application**: Always read the type definition in `node_modules/@opencode-ai/plugin/dist/index.d.ts` for any hook before wiring. If a hook signature has two parameters where the second is an object, it's a mutation hook — return values are discarded. + +--- + +## [2026-04-29-030200] OpenCode shell.env injects env only into agent's shell tool, not into plugin's own ctx.$ calls + +**Context**: After fixing `shell.env`'s `(input, output) => mutate output.env` signature so `CTX_DIR` reached the agent's bash tool, the plugin's own `ctx.$\`ctx system bootstrap\`` calls still failed silently — they ran without `CTX_DIR` and ctx fell back to `~/.context`. The hook fired correctly; the plugin's subprocess side-effects didn't see the env. + +**Lesson**: `shell.env` injects env into the agent's shell-tool invocations. The plugin's own BunShell calls (`ctx.$\`...\``) inherit OpenCode's process env, which is *separate*. Two shells, two envs. + +**Application**: Build an env-aware BunShell once in the plugin factory: `const $ = ctx.$.env({ ...process.env, CTX_DIR: \`${ctx.directory}/.context\` })`. Reuse it for every plugin-initiated subprocess call. `ctx.directory` is the project root from `PluginInput`. + +--- + +## [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 + +**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-14-010134] Constitution forbids context window as a deferral excuse diff --git a/.context/TASKS.md b/.context/TASKS.md index e0ea58010..b5a9ac6ff 100644 --- a/.context/TASKS.md +++ b/.context/TASKS.md @@ -25,6 +25,14 @@ TASK STATUS LABELS: `#in-progress`: currently being worked on (add inline, don't move task) --> +### 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 #skipped:2026-04-26-231517 reason: decided not to do — OpenCode's exit-code semantics make a Cobra-based block-command shim too risky, and the safety-net omission in OpenCode is now treated as permanent (see decision 2026-04-26-231517) + +- [ ] Task 1 +- [ ] Task 2 + ### Misc - [x] If context is not initialized, hooks should not run. Right now they run diff --git a/docs/cli/setup.md b/docs/cli/setup.md index 6a7c291f4..5f1ce8edd 100644 --- a/docs/cli/setup.md +++ b/docs/cli/setup.md @@ -35,6 +35,7 @@ ctx setup [flags] | `cline` | Cline (VS Code extension) | | `aider` | Aider CLI | | `copilot` | GitHub Copilot | +| `opencode` | OpenCode (terminal-first AI coding agent) | | `windsurf` | Windsurf IDE | !!! note "Claude Code Uses the Plugin System" @@ -55,4 +56,7 @@ ctx setup copilot --write ctx setup kiro --write ctx setup cursor --write ctx setup cline --write + +# Generate OpenCode plugin, skills, AGENTS.md, and global MCP config +ctx setup opencode --write ``` diff --git a/docs/home/opencode.md b/docs/home/opencode.md new file mode 100644 index 000000000..11dbdd6f1 --- /dev/null +++ b/docs/home/opencode.md @@ -0,0 +1,185 @@ +--- +# / ctx: https://ctx.ist +# ,'`./ do you remember? +# `.,'\ +# \ Copyright 2026-present Context contributors. +# SPDX-License-Identifier: Apache-2.0 + +title: "ctx for OpenCode" +icon: lucide/terminal +--- + +![ctx](../images/ctx-banner.png) + +## The Problem + +Every OpenCode session starts from zero. You re-explain your architecture, +the AI repeats mistakes it made yesterday, and decisions get rediscovered +instead of remembered. + +**Without ctx:** + +``` +> "Add the validation middleware we discussed" + +I don't have context about previous discussions. Could you describe +what validation middleware you're referring to? +``` + +**With ctx:** + +``` +> "Add the validation middleware we discussed" + +Yes — from the Jan 15 session. You decided on Zod schemas at the +route level (DECISIONS.md #12), and the pattern is in +CONVENTIONS.md. I'll follow the existing middleware in +src/middleware/auth.ts as a reference. +``` + +That's the whole pitch: **your AI remembers**. + +## Setup (One Command) + +Install the `ctx` binary first ([installation docs](getting-started.md#installation)), +then run from your project root: + +```bash +ctx setup opencode --write && ctx init && eval "$(ctx activate)" +``` + +This does three things: + +1. **`ctx setup opencode --write`** — generates the project-local OpenCode plugin, + skills, and `AGENTS.md`, then merges the ctx MCP server into OpenCode's + global config (`~/.config/opencode/opencode.json` or + `$OPENCODE_HOME/opencode.json`). This writes outside the project root + because non-interactive shells (like MCP subprocesses) cannot discover + project-local config — the same reason the Copilot CLI integration + writes to `~/.copilot/mcp-config.json`. +2. **`ctx init`** — creates the `.context/` directory with template files +3. **`eval "$(ctx activate)"`** — binds `CTX_DIR` for your shell + +### What Gets Created + +| File | Purpose | +|------|---------| +| `.opencode/plugins/ctx.ts` | Lifecycle plugin (hooks into `ctx system` commands) | +| `~/.config/opencode/opencode.json` | Global MCP server registration (or `$OPENCODE_HOME/opencode.json`) | +| `AGENTS.md` | Agent instructions (OpenCode reads this natively) | +| `.opencode/skills/ctx-*/SKILL.md` | Slash command skills | + +The plugin is a single file with no runtime dependencies — no `bun install` +or `npm install` needed. OpenCode loads it automatically on launch. + +## What Happens Automatically + +The plugin wires OpenCode lifecycle events to `ctx`. You don't need to +do anything — it just works. + +| Event | What fires | What it does | +|-------|-----------|--------------| +| New session | `session.created` | Warms ctx state in the background (bootstrap + agent packet) so MCP queries are fast on first use | +| Agent idle | `session.idle` | Runs persistence and task-completion checks (silent — output is buffered, not surfaced to the TUI) | +| After `git commit` | `tool.execute.after` | Runs `ctx system post-commit` to capture context state | +| After file edit | `tool.execute.after` | Runs `ctx system check-task-completion` to detect silent task completions | +| Every shell call | `shell.env` | Injects `CTX_DIR` so all `ctx` commands in the agent's shell resolve to the right project | +| Context compaction | `experimental.session.compacting` | Pushes `ctx system bootstrap` output into the compaction context so the agent retains breadcrumbs to re-read context files post-compaction | + +The compaction hook matters most. When OpenCode compresses your context +window to free up tokens, the plugin makes sure the compressed summary +includes a pointer back to your `.context/` directory and its file +inventory — so the agent can re-read tasks, decisions, and learnings on +demand, even though the original messages are gone. + +### How Compaction Works + +When your conversation exceeds the context window, OpenCode runs a +compaction pass (you can trigger one manually with `/compact`). The +compaction agent summarizes older messages and drops the originals. Without +ctx, all accumulated knowledge disappears. With ctx, the plugin intercepts +the `experimental.session.compacting` event and appends `ctx system bootstrap` +output (context directory path and file inventory) into the compaction +context. The result: the compressed summary retains the breadcrumbs the +agent needs to re-read tasks, decisions, learnings, and conventions +on demand, even though the original messages that loaded them are gone. + +### What Is *Not* Included + +Note: dangerous-command blocking is Claude Code-specific and is not part of +the OpenCode integration. OpenCode's execution model (explicit user +approval for every shell command) makes a pre-execution blocklist +unnecessary. + +## Slash Commands + +Four skills are available as slash commands: + +| Command | When to use | +|---------|-------------| +| `/ctx-agent` | Load full context packet. Use at session start or when context feels stale. | +| `/ctx-remember` | "Do you remember?" — reads tasks, decisions, learnings, and recent journal entries. Returns a structured readback. | +| `/ctx-status` | Context summary at a glance: file count, token estimate, recent activity. | +| `/ctx-wrap-up` | End-of-session ceremony. Captures learnings, decisions, conventions, and outstanding tasks to `.context/` files. | + +You don't need to use these often. The plugin handles most context loading +automatically. These are for when you want explicit control. + +## MCP Tools + +The ctx MCP server exposes tools directly to the agent. These let the AI +read and write your context files without shell commands: + +| Tool | Purpose | +|------|---------| +| `ctx_add` | Add a task, decision, learning, or convention | +| `ctx_complete` | Mark a task done by number or text match | +| `ctx_search` | Full-text search across all `.context/` files | +| `ctx_next` | Suggest the next pending task by priority | +| `ctx_drift` | Detect stale context: dead paths, missing files | +| `ctx_compact` | Archive completed tasks, clean empty sections | +| `ctx_remind` | List pending session-scoped reminders | +| `ctx_status` | Context health: file count, token estimate | +| `ctx_steering_get` | Retrieve steering files applicable to the current prompt | +| `ctx_journal_source` | Query recent AI session history | +| `ctx_session_event` | Signal session start/end lifecycle events | +| `ctx_watch_update` | Apply structured updates to `.context/` files | +| `ctx_check_task_completion` | After a write, detect silently completed tasks | + +You don't invoke these yourself. The agent uses them as needed. + +## Refreshing the Integration + +If you re-run `ctx setup opencode --write` (e.g., after updating ctx), the +plugin and skills are rewritten in place. **Restart OpenCode to pick up the +refreshed plugin** — OpenCode only loads plugins at launch, not mid-session. + +## Troubleshooting + +| Symptom | Cause | Fix | +|---------|-------|-----| +| `opencode mcp list` shows `ctx ✗ failed MCP error -32000: Connection closed` | `CTX_DIR` not resolving in the MCP subprocess | Re-run `ctx setup opencode --write` to regenerate the sh-wrapper that sets `CTX_DIR` | +| Plugin installed but no hooks fire | Flat-file vs. subdirectory discovery mismatch (OpenCode requires `.opencode/plugins/.ts`, not a subfolder) | Verify the plugin is at `.opencode/plugins/ctx.ts`. Check with `opencode --print-logs --log-level DEBUG` | +| `ctx agent` markdown leaking into the TUI | BunShell command missing `.nothrow().quiet()` | Update to the latest plugin: `ctx setup opencode --write` and restart | + +## Verify It Works + +Start a new OpenCode session and ask: + +``` +Do you remember? +``` + +The AI should cite specific context: current tasks, recent decisions, or +previous session topics. If it says "I don't have memory" or "Let me +check," something went wrong — check that the plugin installed correctly +and `.context/` has files in it. + +## What's Next + +- [Your First Session](first-session.md) — step-by-step walkthrough from + `ctx init` to verified recall +- [Common Workflows](common-workflows.md) — day-to-day commands for + tracking context, checking health, and browsing history +- [Context Files](context-files.md) — what lives in `.context/` and how + each file is used diff --git a/docs/operations/integrations.md b/docs/operations/integrations.md index 272604a94..6808d9a8d 100644 --- a/docs/operations/integrations.md +++ b/docs/operations/integrations.md @@ -576,6 +576,57 @@ 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, global 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.ts` | Lifecycle plugin (hooks to `ctx system`) | +| `~/.config/opencode/opencode.json` | Global MCP server registration (or `$OPENCODE_HOME/opencode.json`) | +| `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`** — warms ctx state in the background (bootstrap + agent packet) so MCP queries are fast on first use +- **`tool.execute.after` (shell, on `git commit`)** — runs `ctx system post-commit` +- **`tool.execute.after` (edit/write)** — runs `ctx system check-task-completion` +- **`session.idle`** — runs persistence and task-completion checks (silent: output is buffered, not surfaced to the TUI) +- **`shell.env`** — injects `CTX_DIR` into the agent's shell so `ctx` commands resolve to the right project +- **`experimental.session.compacting`** — pushes `ctx system bootstrap` output into the compaction context so the agent keeps breadcrumbs back to `.context/` + +The plugin is a single file with no runtime dependencies — no `bun install` +needed. OpenCode loads it automatically on launch. + +### 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/docs/recipes/guide-your-agent.md b/docs/recipes/guide-your-agent.md index 2d2c44e93..9a7c0eb33 100644 --- a/docs/recipes/guide-your-agent.md +++ b/docs/recipes/guide-your-agent.md @@ -46,7 +46,7 @@ showing these natural-language patterns. ## Next Up **[Setup Across AI Tools →](multi-tool-setup.md)**: Initialize ctx -and configure hooks for Claude Code, Cursor, Aider, Copilot, or +and configure hooks for Claude Code, OpenCode, Cursor, Aider, Copilot, or Windsurf. ## See Also diff --git a/docs/recipes/index.md b/docs/recipes/index.md index 5b043a426..4ace33557 100644 --- a/docs/recipes/index.md +++ b/docs/recipes/index.md @@ -21,7 +21,7 @@ Train your agent to be proactive through **ask, guide, reinforce**. ### [Setup across AI Tools](multi-tool-setup.md) -Initialize `ctx` and configure hooks for Claude Code, Cursor, +Initialize `ctx` and configure hooks for Claude Code, OpenCode, Cursor, Aider, Copilot, or Windsurf. Includes **shell completion**, **watch mode** for non-native tools, and **verification**. diff --git a/docs/recipes/multi-tool-setup.md b/docs/recipes/multi-tool-setup.md index a3888ff81..f392683cd 100644 --- a/docs/recipes/multi-tool-setup.md +++ b/docs/recipes/multi-tool-setup.md @@ -31,6 +31,9 @@ source <(ctx completion zsh) # shell completion (or bash/fish) claude /plugin marketplace add ActiveMemory/ctx claude /plugin install ctx@activememory-ctx +# ## OpenCode ## +ctx setup opencode --write && ctx init && eval "$(ctx activate)" + # ## Cursor / Aider / Copilot / Windsurf ## ctx setup cursor # or: aider, copilot, windsurf @@ -165,6 +168,23 @@ as `ActiveMemory/ctx`. `ctx agent --budget 4000` on every tool call (*with a 10-minute cooldown so it only fires once per window*). +#### OpenCode + +Run the one-liner from the project root: + +```bash +ctx setup opencode --write && ctx init && eval "$(ctx activate)" +``` + +This deploys a lifecycle plugin, slash command skills, `AGENTS.md`, and +registers the ctx MCP server globally. See +[ctx for OpenCode](../home/opencode.md) for full details. + +!!! tip "OpenCode Is a First-Class Citizen" + With the plugin installed, OpenCode gets lifecycle hooks and skills + automatically. Context loads at session start, survives compaction, + and persists at session end — no manual steps needed. + #### Cursor Add the system prompt snippet to `.cursor/settings.json`: @@ -338,6 +358,10 @@ source <(ctx completion zsh) # or bash/fish # ## Claude Code (automatic, just verify) ## # Start Claude Code, then ask: "Do you remember?" +# ## OpenCode ## +ctx setup opencode --write +# Start OpenCode, then ask: "Do you remember?" + # ## Cursor ## ctx setup cursor # Add the system prompt to .cursor/settings.json diff --git a/internal/assets/commands/text/errors.yaml b/internal/assets/commands/text/errors.yaml index 37112b0c4..86291f44f 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/commands/text/hooks.yaml b/internal/assets/commands/text/hooks.yaml index e329f83e2..6f3ff8287 100644 --- a/internal/assets/commands/text/hooks.yaml +++ b/internal/assets/commands/text/hooks.yaml @@ -422,6 +422,25 @@ hook.copilot-cli: Run with --write to generate all files: ctx setup copilot-cli --write +hook.opencode: + short: | + OpenCode Integration + ==================== + + Generate .opencode/plugins/ctx.ts with ctx lifecycle hooks + and register the ctx MCP server in OpenCode's global config + (`~/.config/opencode/opencode.json` or + `$OPENCODE_HOME/opencode.json`). + + This creates: + .opencode/plugins/ctx.ts Plugin shim + .opencode/skills/ctx-*/SKILL.md ctx skills + ~/.config/opencode/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 +450,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 1b5184eb2..5d8c80ccd 100644 --- a/internal/assets/commands/text/write.yaml +++ b/internal/assets/commands/text/write.yaml @@ -112,6 +112,23 @@ 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 (already present, skipped)' +write.hook-opencode-summary: + short: |- + OpenCode will now: + 1. Bootstrap ctx in the background on session start + 2. Nudge persistence on session idle + 3. Track task completion after edits + 4. Run post-commit capture after `git commit` + 5. Inject ctx state into compaction prompts (preserve context) + + Plugin: .opencode/plugins/ctx.ts + MCP: ~/.config/opencode/opencode.json (or $OPENCODE_HOME/opencode.json) + AGENTS: AGENTS.md + 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..6076bce51 100644 --- a/internal/assets/embed.go +++ b/internal/assets/embed.go @@ -18,6 +18,8 @@ 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/plugin/index.ts +//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/plugin/index.ts b/internal/assets/integrations/opencode/plugin/index.ts new file mode 100644 index 000000000..49f6ef2b9 --- /dev/null +++ b/internal/assets/integrations/opencode/plugin/index.ts @@ -0,0 +1,84 @@ +// 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. +// +// Hook signatures match @opencode-ai/plugin v1.4.x: +// - shell.env, tool.execute.after, and +// experimental.session.compacting take (input, output) and +// mutate output rather than returning a value. +// - event is a single dispatcher keyed on input.event.type; +// it is NOT an object of named per-event handlers. +// ctx subprocess calls go through a CTX_DIR-aware BunShell built +// from ctx.directory — shell.env only injects CTX_DIR into the +// agent's shell tool, not into the plugin's own ctx.$ calls. +// All ctx.$ invocations use .nothrow().quiet(): nothrow swallows +// non-zero exits, quiet keeps stdout/stderr in BunShell's buffer +// instead of echoing to OpenCode's process stdout (which would +// surface as visible noise in the TUI or agent context). +// experimental.session.compacting pushes to output.context (does +// NOT set output.prompt) so it composes additively with other +// compaction-aware plugins like oh-my-openagent. +// If the upstream renames a hook or changes a signature, the +// corresponding branch silently no-ops; verify against the +// OpenCode plugin SDK type definitions 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(?!-)/ + +// extractCommand pulls the shell command string out of a tool.execute.after +// input. Today the OpenCode SDK's bash tool sends args as either a raw +// string or { command: string }. If a future SDK bump sends command as +// an array (e.g. ["git", "commit"]), this returns "" and the post-commit +// regex will silently miss — verify against the SDK type definitions +// when bumping @opencode-ai/plugin. +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 (async (ctx) => { + const ctxDir = `${ctx.directory}/.context` + const $ = ctx.$.env({ ...process.env, CTX_DIR: ctxDir }) + return { + "shell.env": async (input, output) => { + output.env.CTX_DIR = `${input.cwd}/.context` + }, + event: async ({ event }) => { + if (event.type === "session.created") { + await $`ctx system bootstrap`.nothrow().quiet() + await $`ctx agent --budget 4000`.nothrow().quiet() + } else if (event.type === "session.idle") { + await $`ctx system check-persistence`.nothrow().quiet() + await $`ctx system check-task-completion`.nothrow().quiet() + } + }, + "tool.execute.after": async (input, _output) => { + if (SHELL_TOOLS.has(input.tool)) { + const cmd = extractCommand(input.args) + if (GIT_COMMIT_RE.test(cmd)) { + await $`ctx system post-commit`.nothrow().quiet() + } + } + if (EDIT_TOOLS.has(input.tool)) { + await $`ctx system check-task-completion`.nothrow().quiet() + } + }, + "experimental.session.compacting": async (_input, output) => { + const result = await $`ctx system bootstrap`.nothrow().quiet() + if (result.exitCode === 0) { + const text = result.stdout.toString().trim() + if (text.length > 0) { + output.context.push(`ctx context state (preserved across compaction):\n${text}`) + } + } + }, + } +}) satisfies Plugin 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..50d74e7ae --- /dev/null +++ b/internal/assets/integrations/opencode/skills/ctx-remember/SKILL.md @@ -0,0 +1,29 @@ +--- +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. Check recent session history (for example via `.context/sessions/` or `ctx journal source --limit 5` when available) +3. 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..1674de6ad --- /dev/null +++ b/internal/assets/integrations/opencode/skills/ctx-status/SKILL.md @@ -0,0 +1,30 @@ +--- +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 +``` + +The slash command takes no arguments. For verbose or JSON output, ask the +agent to run `ctx status --verbose` or `ctx status --json` directly. 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/agents/agents.go b/internal/cli/setup/core/agents/agents.go index b37fbe183..98d127a2e 100644 --- a/internal/cli/setup/core/agents/agents.go +++ b/internal/cli/setup/core/agents/agents.go @@ -36,24 +36,28 @@ import ( func Deploy(cmd *cobra.Command) error { targetFile := cfgHook.FileAgentsMd - // Load the AGENTS.md template agentsContent, readErr := agent.AgentsMd() if readErr != nil { return readErr } - // Check if the file exists - existingContent, err := io.SafeReadUserFile(filepath.Clean(targetFile)) - fileExists := err == nil + fileExists, validateErr := validateTargetFile(targetFile) + if validateErr != nil { + return validateErr + } if fileExists { + existingContent, err := io.SafeReadUserFile(filepath.Clean(targetFile)) + if err != nil { + return errFs.FileRead(targetFile, err) + } + existingStr := string(existingContent) if strings.Contains(existingStr, marker.AgentsStart) { writeSetup.InfoAgentsSkipped(cmd, targetFile) return nil } - // File exists without ctx markers: append ctx content merged := existingStr + token.NewlineLF + string(agentsContent) wErr := io.SafeWriteFile( targetFile, []byte(merged), fs.PermFile, @@ -65,7 +69,6 @@ func Deploy(cmd *cobra.Command) error { return nil } - // File doesn't exist: create it wErr := io.SafeWriteFile( targetFile, agentsContent, fs.PermFile, ) diff --git a/internal/cli/setup/core/agents/agents_test.go b/internal/cli/setup/core/agents/agents_test.go new file mode 100644 index 000000000..603529808 --- /dev/null +++ b/internal/cli/setup/core/agents/agents_test.go @@ -0,0 +1,159 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package agents + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" + "github.com/ActiveMemory/ctx/internal/assets/read/lookup" + "github.com/ActiveMemory/ctx/internal/config/marker" + "github.com/spf13/cobra" +) + +func TestMain(m *testing.M) { + lookup.Init() + os.Exit(m.Run()) +} + +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func withTempProjectDir(t *testing.T) string { + t.Helper() + + tmp := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + return tmp +} + +func TestDeploy_CreatesFileWhenAbsent(t *testing.T) { + withTempProjectDir(t) + + var buf bytes.Buffer + if err := Deploy(testCmd(&buf)); err != nil { + t.Fatalf("Deploy() error = %v", err) + } + + data, err := os.ReadFile(filepath.Clean("AGENTS.md")) + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + if !strings.Contains(string(data), marker.AgentsStart) { + t.Fatalf("AGENTS.md missing ctx marker: %q", string(data)) + } + if !strings.Contains(buf.String(), "✓") { + t.Fatalf("expected create output, got %q", buf.String()) + } +} + +func TestDeploy_MergesExistingFileWithoutMarkers(t *testing.T) { + withTempProjectDir(t) + + const existing = `# Local Instructions + +Do local things.` + if err := os.WriteFile("AGENTS.md", []byte(existing), 0o644); err != nil { + t.Fatalf("seed AGENTS.md: %v", err) + } + + template, err := agent.AgentsMd() + if err != nil { + t.Fatalf("read embedded AGENTS.md: %v", err) + } + + var buf bytes.Buffer + if deployErr := Deploy(testCmd(&buf)); deployErr != nil { + t.Fatalf("Deploy() error = %v", deployErr) + } + + data, err := os.ReadFile(filepath.Clean("AGENTS.md")) + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + got := string(data) + if !strings.Contains(got, existing) { + t.Fatalf("merged file lost existing content: %q", got) + } + if !strings.Contains(got, string(template)) { + t.Fatalf("merged file missing ctx template") + } + if !strings.Contains(buf.String(), "merged") { + t.Fatalf("expected merge output, got %q", buf.String()) + } +} + +func TestDeploy_SkipsExistingFileWithMarkers(t *testing.T) { + withTempProjectDir(t) + + existing := marker.AgentsStart + ` +custom ctx-managed section +` + if err := os.WriteFile("AGENTS.md", []byte(existing), 0o644); err != nil { + t.Fatalf("seed AGENTS.md: %v", err) + } + + var buf bytes.Buffer + if deployErr := Deploy(testCmd(&buf)); deployErr != nil { + t.Fatalf("Deploy() error = %v", deployErr) + } + + data, err := os.ReadFile(filepath.Clean("AGENTS.md")) + if err != nil { + t.Fatalf("read AGENTS.md: %v", err) + } + if string(data) != existing { + t.Fatalf("AGENTS.md should be unchanged; got %q", string(data)) + } + if !strings.Contains(buf.String(), "skipped") { + t.Fatalf("expected skip output, got %q", buf.String()) + } +} + +func TestDeploy_RejectsSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior varies on Windows in this environment") + } + + tmp := withTempProjectDir(t) + realFile := filepath.Join(t.TempDir(), "outside.md") + if err := os.WriteFile(realFile, []byte("secret"), 0o644); err != nil { + t.Fatalf("seed real file: %v", err) + } + if err := os.Symlink(realFile, filepath.Join(tmp, "AGENTS.md")); err != nil { + t.Fatalf("create symlink: %v", err) + } + + err := Deploy(testCmd(&bytes.Buffer{})) + if err == nil { + t.Fatal("expected symlink rejection, got nil") + } +} + +func TestDeploy_RejectsNonRegularTarget(t *testing.T) { + tmp := withTempProjectDir(t) + if err := os.Mkdir(filepath.Join(tmp, "AGENTS.md"), 0o755); err != nil { + t.Fatalf("mkdir AGENTS.md: %v", err) + } + + err := Deploy(testCmd(&bytes.Buffer{})) + if err == nil { + t.Fatal("expected non-regular file rejection, got nil") + } +} diff --git a/internal/cli/setup/core/agents/validate.go b/internal/cli/setup/core/agents/validate.go new file mode 100644 index 000000000..e6734d036 --- /dev/null +++ b/internal/cli/setup/core/agents/validate.go @@ -0,0 +1,42 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package agents + +import ( + "os" + + errFs "github.com/ActiveMemory/ctx/internal/err/fs" +) + +// validateTargetFile rejects symlinks and non-regular files before +// AGENTS.md is read or merged. +// +// Parameters: +// - targetFile: file path to validate +// +// Returns: +// - bool: true when the path exists and passed validation. +// - error: non-nil when stat fails or the path is not a regular file. +func validateTargetFile(targetFile string) (bool, error) { + fi, lstatErr := os.Lstat(targetFile) + if lstatErr != nil { + if os.IsNotExist(lstatErr) { + return false, nil + } + return false, errFs.StatPath(targetFile, lstatErr) + } + + if fi.Mode()&os.ModeSymlink != 0 { + return false, errFs.FileRead(targetFile, os.ErrInvalid) + } + + if !fi.Mode().IsRegular() { + return false, errFs.FileRead(targetFile, os.ErrInvalid) + } + + return true, nil +} diff --git a/internal/cli/setup/core/copilot_cli/mcp.go b/internal/cli/setup/core/copilot_cli/mcp.go index 933eec9da..a646bc547 100644 --- a/internal/cli/setup/core/copilot_cli/mcp.go +++ b/internal/cli/setup/core/copilot_cli/mcp.go @@ -87,7 +87,7 @@ func ensureMCPConfig(cmd *cobra.Command) error { } data = append(data, token.NewlineLF...) - writeFileErr := io.SafeWriteFile( + writeFileErr := io.SafeWriteFileAtomic( target, data, fs.PermFile, ) if writeFileErr != nil { diff --git a/internal/cli/setup/core/opencode/deploy_test.go b/internal/cli/setup/core/opencode/deploy_test.go new file mode 100644 index 000000000..dcac5e14d --- /dev/null +++ b/internal/cli/setup/core/opencode/deploy_test.go @@ -0,0 +1,124 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "bytes" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/ActiveMemory/ctx/internal/assets/read/agent" +) + +func withTempProjectDir(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + origDir, _ := os.Getwd() + if err := os.Chdir(tmp); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origDir) }) + return tmp +} + +func TestDeployPlugin_RefreshesStalePlugin(t *testing.T) { + withTempProjectDir(t) + target := filepath.Join(".opencode", "plugins", "ctx.ts") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(target, []byte("stale plugin"), 0o644); err != nil { + t.Fatalf("seed plugin: %v", err) + } + + var buf bytes.Buffer + if err := deployPlugin(testCmd(&buf)); err != nil { + t.Fatalf("deployPlugin: %v", err) + } + + files, err := agent.OpenCodePlugin() + if err != nil { + t.Fatalf("OpenCodePlugin: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read plugin: %v", err) + } + if !bytes.Equal(got, files["index.ts"]) { + t.Fatalf("plugin not refreshed") + } + if bytes.Contains(buf.Bytes(), []byte("skipped")) { + t.Fatalf("expected refresh, got skipped output %q", buf.String()) + } +} + +func TestDeploySkills_RefreshesStaleSkill(t *testing.T) { + withTempProjectDir(t) + target := filepath.Join(".opencode", "skills", "ctx-agent", "SKILL.md") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + if err := os.WriteFile(target, []byte("stale skill"), 0o644); err != nil { + t.Fatalf("seed skill: %v", err) + } + + var buf bytes.Buffer + if err := deploySkills(testCmd(&buf)); err != nil { + t.Fatalf("deploySkills: %v", err) + } + + skills, err := agent.OpenCodeSkills() + if err != nil { + t.Fatalf("OpenCodeSkills: %v", err) + } + got, err := os.ReadFile(target) + if err != nil { + t.Fatalf("read skill: %v", err) + } + if !bytes.Equal(got, skills["ctx-agent"]) { + t.Fatalf("skill not refreshed") + } + if bytes.Contains(buf.Bytes(), []byte("skipped")) { + t.Fatalf("expected refresh, got skipped output %q", buf.String()) + } +} + +func TestDeployPlugin_RejectsSymlinkTarget(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink behavior varies on Windows in this environment") + } + withTempProjectDir(t) + target := filepath.Join(".opencode", "plugins", "ctx.ts") + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + realFile := filepath.Join(t.TempDir(), "outside.ts") + if err := os.WriteFile(realFile, []byte("secret"), 0o644); err != nil { + t.Fatalf("seed real file: %v", err) + } + if err := os.Symlink(realFile, target); err != nil { + t.Fatalf("symlink: %v", err) + } + + if err := deployPlugin(testCmd(&bytes.Buffer{})); err == nil { + t.Fatal("expected symlink rejection, got nil") + } +} + +func TestDeploySkills_RejectsNonRegularTarget(t *testing.T) { + withTempProjectDir(t) + target := filepath.Join(".opencode", "skills", "ctx-agent", "SKILL.md") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatalf("mkdir target dir: %v", err) + } + + if err := deploySkills(testCmd(&bytes.Buffer{})); err == nil { + t.Fatal("expected non-regular target rejection, got 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..2d7722142 --- /dev/null +++ b/internal/cli/setup/core/opencode/doc.go @@ -0,0 +1,26 @@ +// / 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: writes .opencode/plugins/ctx.ts as a +// flat top-level file (subdirectories are ignored by +// OpenCode's auto-loader) +// 2. MCP configuration: merges ctx server into the global +// OpenCode config (~/.config/opencode/opencode.json or +// $OPENCODE_HOME/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..2d07023c2 --- /dev/null +++ b/internal/cli/setup/core/opencode/mcp.go @@ -0,0 +1,188 @@ +// / ctx: https://ctx.ist +// ,'`./ do you remember? +// `.,'\ +// \ Copyright 2026-present Context contributors. +// SPDX-License-Identifier: Apache-2.0 + +package opencode + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "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" + 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" +) + +// launchCommand returns the OpenCode `command` array for the ctx +// MCP server. The emitted argv is: +// +// ["sh", "-c", `exec env CTX_DIR="$PWD/.context" /abs/path/to/ctx mcp serve`] +// +// The binary path is resolved to an absolute path at setup time via +// exec.LookPath, so that OpenCode can spawn the MCP child regardless +// of the PATH inherited by non-interactive shells. `$PWD` is set by +// sh to the CWD OpenCode chose when spawning the MCP child. `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 { + bin := mcpServer.Command + if resolved, err := exec.LookPath(bin); err == nil { + if abs, absErr := filepath.Abs(resolved); absErr == nil { + bin = abs + } + } + binAndArgs := append([]string{bin}, mcpServer.Args()...) + quoted := make([]string, 0, len(binAndArgs)) + for _, arg := range binAndArgs { + quoted = append(quoted, posixShellQuote(arg)) + } + script := fmt.Sprintf( + cfgShell.FormatPOSIXSpawnRelativeCtxDir, + env.CtxDir, cfgDir.Context, + strings.Join(quoted, token.Space), + ) + return []string{cfgShell.Sh, cfgShell.CmdFlag, script} +} + +// posixShellQuote wraps s in single quotes, escaping embedded single +// quotes using the canonical close-escape-reopen POSIX pattern so the +// resulting token is safe to embed in a `sh -c` script. +// +// Parameters: +// - s: raw argv token to quote for POSIX shell evaluation +// +// Returns: +// - string: single-quoted, escape-safe shell token +func posixShellQuote(s string) string { + return cfgShell.SingleQuote + + strings.ReplaceAll(s, cfgShell.SingleQuote, cfgShell.SingleQuoteEscaped) + + cfgShell.SingleQuote +} + +// globalConfigPath returns the path to the OpenCode global config +// file (~/.config/opencode/opencode.json or $OPENCODE_HOME/opencode.json). +// +// Returns: +// - string: absolute path to the OpenCode config file. +// - error: non-nil when the user home directory cannot be resolved. +func globalConfigPath() (string, error) { + ocHome := os.Getenv(cfgHook.EnvOpenCodeHome) + if ocHome == "" { + home, homeErr := os.UserHomeDir() + if homeErr != nil { + return "", homeErr + } + ocHome = filepath.Join( + home, cfgHook.DirXDGConfig, cfgHook.DirOpenCodeHome, + ) + } + return filepath.Join(ocHome, cfgHook.FileOpenCodeJSON), nil +} + +// ensureMCPConfig registers the ctx MCP server in the OpenCode +// global config file (~/.config/opencode/opencode.json). +// +// Merge-safe: reads existing config, adds or updates the ctx +// server under the "mcp" key, writes back, and preserves all +// unrelated config keys. Treats a missing or empty file as "no +// existing config" rather than an error. +// +// Parameters: +// - cmd: Cobra command for output messages +// +// Returns: +// - error: Non-nil if file read/write fails +func ensureMCPConfig(cmd *cobra.Command) error { + target, pathErr := globalConfigPath() + if pathErr != nil { + return pathErr + } + + if _, validateErr := validateManagedTarget(target); validateErr != nil { + return validateErr + } + + existing := make(map[string]interface{}) + data, readErr := ctxIo.SafeReadUserFile(target) + if readErr == nil { + if len(bytes.TrimSpace(data)) > 0 { + if jErr := json.Unmarshal(data, &existing); jErr != nil { + return errSetup.MarshalConfig(jErr) + } + } + } else if !os.IsNotExist(readErr) { + return errFs.FileRead(target, readErr) + } + + servers, _ := existing[cfgHook.KeyMCP].(map[string]interface{}) + if servers == nil { + servers = make(map[string]interface{}) + } + + newServer := map[string]interface{}{ + cfgHook.KeyType: cfgHook.MCPServerType, + cfgHook.KeyCommand: launchCommand(), + cfgHook.KeyEnabled: true, + } + if existingServer, ok := servers[mcpServer.Name]; ok { + if existingMap, mapOK := existingServer.(map[string]interface{}); mapOK { + current, marshalErr := json.Marshal(existingMap) + if marshalErr != nil { + return errSetup.MarshalConfig(marshalErr) + } + expected, marshalErr := json.Marshal(newServer) + if marshalErr != nil { + return errSetup.MarshalConfig(marshalErr) + } + if bytes.Equal(current, expected) { + writeSetup.InfoOpenCodeSkipped(cmd, target) + return nil + } + } + } + + servers[mcpServer.Name] = newServer + existing[cfgHook.KeyMCP] = servers + + // Ensure the directory exists. + dir := filepath.Dir(target) + if mkErr := ctxIo.SafeMkdirAll(dir, fs.PermExec); mkErr != nil { + return errFs.Mkdir(dir, mkErr) + } + + out, marshalErr := json.MarshalIndent( + existing, "", token.Indent2, + ) + if marshalErr != nil { + return errSetup.MarshalConfig(marshalErr) + } + out = append(out, token.NewlineLF...) + + if writeFileErr := ctxIo.SafeWriteFileAtomic( + target, out, fs.PermFile, + ); writeFileErr != nil { + return errFs.FileWrite(target, writeFileErr) + } + writeSetup.InfoOpenCodeCreated(cmd, target) + return nil +} 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..13888329b --- /dev/null +++ b/internal/cli/setup/core/opencode/mcp_test.go @@ -0,0 +1,281 @@ +// / 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" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func testCmd(buf *bytes.Buffer) *cobra.Command { + cmd := &cobra.Command{} + cmd.SetOut(buf) + return cmd +} + +func setOpenCodeHome(t *testing.T) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("OPENCODE_HOME", tmp) + return tmp +} + +func configPath(home string) string { + return filepath.Join(home, "opencode.json") +} + +func readMCP(t *testing.T, path string) map[string]interface{} { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read config: %v", err) + } + parsed := map[string]interface{}{} + if err := json.Unmarshal(raw, &parsed); err != nil { + t.Fatalf("config not valid JSON: %v", err) + } + return parsed +} + +func TestEnsureMCPConfig_CreatesFile(t *testing.T) { + home := setOpenCodeHome(t) + + var buf bytes.Buffer + if err := ensureMCPConfig(testCmd(&buf)); err != nil { + t.Fatalf("ensureMCPConfig: %v", err) + } + + parsed := readMCP(t, configPath(home)) + 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["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.Fatalf("command length = %d, want 3 (sh -c