Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ coverage/
# Local editor / agent tooling
.opencode/
.claude/
.cursor/

# Local-only dev config (never commit)
opencode.json
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

- **Skills bridge: opencode skills are now mirrored into `.cursor/skills/` for
the Cursor agent.** Both project-scoped and global skills are discovered
(matching opencode's resolution order: `.opencode/skills/`, `.claude/skills/`,
`.agents/skills/`, walked up to the git worktree root, plus global
`~/.config/opencode/skills/` etc.), filtered through opencode's `permission`
config, and materialised as a git-ignored mirror with a `generated:
opencode-cursor` sentinel. An `<available_skills>` catalogue is appended to
the generated system rule so the Cursor agent can discover and load skills on
demand. Works for the primary agent, Cursor sub-agents, and `cursor_delegate`
(which passes `settingSources: ["project"]`). `ask`-permissioned skills are
withheld (the ask prompt can't cross the Cursor boundary). Opt out with
`forwardSkills: false`; manual override with `skills: { include, exclude }`.
User-owned `.cursor/skills/<id>/` directories are never overwritten.
`config.skills.paths` directories are also scanned (lowest priority,
first-wins on duplicate ids). `config.skills.urls` (HTTP catalogs) are not
yet supported.

## [0.6.1] — 2026-07-24

- **Fixed: reasoning/thinking variants showed as meaningless numbered entries for
Expand Down
80 changes: 76 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,8 @@ See [SECURITY.md](./SECURITY.md) for the full threat model.
| `session` | `"auto"` | Session reuse strategy — see [Session reuse](#session-reuse-session) |
| `forwardMcp` | `true` | Forward opencode's configured MCP servers to the Cursor agent |
| `mcpServers` | — | Extra MCP servers (Cursor `McpServerConfig` shape); merged with forwarded ones |
| `forwardSkills` | `true` | Mirror opencode's resolved skills into `.cursor/skills/` — see [Skills](#skills) |
| `skills` | — | Manual skill override: `{ include?: string[], exclude?: string[] }` — see [Skills](#skills) |
| `toolDisplay` | `"blocks"` | How Cursor's internal tool activity is shown — see [Tool display](#tool-display) |
| `systemPrompt` | `"rules"` | How opencode's system prompt reaches the agent — see [System prompt](#system-prompt) |
| `transport` | — | Cursor agent transport (`"http1"` \| `"http2-direct"` \| `"sidecar"`) — see [Transport](#transport) |
Expand Down Expand Up @@ -241,9 +243,77 @@ Disabled entries (`enabled: false`) are skipped. Remote servers requiring OAuth
shareable `clientId` are also skipped (a one-time toast says which). Disable forwarding with
`forwardMcp: false`.

> **Note:** This forwards MCP **servers**. opencode's own skills and subagents are not exposed to
> the Cursor agent. To load your local Cursor skills/rules, use
> `settingSources: ["project","user"]`.
> **Note:** This forwards MCP **servers**. opencode's skills are mirrored into
> `.cursor/skills/` automatically — see [Skills](#skills).

## Skills

With `forwardSkills: true` (default), the plugin mirrors opencode's resolved
skills into `<cwd>/.cursor/skills/` — a git-ignored directory that Cursor
discovers natively when the `project` settings layer is loaded. This works for
both the primary agent and any Cursor sub-agent, because the mirror is written
at plugin init (before any turn) and `settingSources` lives on the provider
config (which survives the sub-agent model-options drop).

The mirror includes:

- **Project skills** from `.opencode/skills/`, `.opencode/skill/`,
`.claude/skills/`, `.agents/skills/` (walked up to the git worktree root).
- **Global skills** from `~/.config/opencode/skills/` and `~/.config/opencode/skill/`,
`~/.claude/skills/`, `~/.agents/skills/`, `~/.opencode/skills/` and `~/.opencode/skill/`.
- **Configured paths** from `config.skills.paths` in your `opencode.json` — additional
directories scanned at the lowest priority (project and standard global locations
win on duplicate ids). `~/` prefixes are expanded to your home directory; relative
paths are resolved against the project directory.
- **Supporting files** alongside each `SKILL.md` (preserving relative paths).
- An `<available_skills>` catalogue appended to the generated system rule,
listing each skill's id and description so the Cursor agent can load them on
demand.

> **Note:** `config.skills.urls` (HTTP skill catalogs) are not yet supported by
> the mirror. If you rely on URL-sourced skills, they will not appear in
> `.cursor/skills/`.

### Permission filtering

Skills are filtered through opencode's live `permission` config before
mirroring:

- **`allow`** (default): included in the mirror.
- **`deny`**: excluded entirely.
- **`ask`**: excluded — the ask prompt can't be enforced across the Cursor
boundary. The plugin logs which skills were withheld and why.

### Manual override

```json
{
"provider": {
"cursor": {
"options": {
"skills": {
"include": ["my-skill", "other-skill"],
"exclude": ["internal-*"]
}
}
}
}
}
```

`include` keeps only the listed skills (and overrides `deny` permission — the
user explicitly asked for them). `exclude` always drops the listed skills.

### Limitations

- A user-owned `.cursor/skills/<id>/SKILL.md` (without the `generated:
opencode-cursor` sentinel) is never overwritten or deleted.
- Individual files larger than 1 MB are skipped (the rest of the skill is still
mirrored). Total mirror size is capped at 10 MB.
- `cursor_delegate` with a `cwd` different from the session directory does not
mirror skills into that cwd (but does pass `settingSources: ["project"]` so
any pre-existing `.cursor/skills/` there still loads).
- `cursor_cloud_agent` targets a remote repo and does not inherit skills.

## Delegation tools

Expand All @@ -257,7 +327,9 @@ are gated by opencode's `permission` config:
### `cursor_delegate` (local)

Runs one Cursor turn as a permission-gated tool call. Your primary opencode model hands off a
discrete subtask and gets the result back.
discrete subtask and gets the result back. The delegate passes `settingSources: ["project"]` so
any pre-existing `.cursor/skills/` in the delegate's cwd loads (skills are not mirrored into a
non-session cwd — see [Skills limitations](#limitations)).

| Arg | Required | Meaning |
| --- | --- | --- |
Expand Down
27 changes: 27 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,33 @@ your opencode `permission` config (`allow` / `ask` / `deny`) controls them. The
gate is **fail-closed**: if no permission mechanism is available, or approval is
rejected, the call is blocked rather than silently allowed.

### Skills mirror — instructions cross the permission boundary

When `forwardSkills` is enabled (default), opencode's resolved skills are
mirrored into `<cwd>/.cursor/skills/` so the Cursor agent can discover and load
them. This means **skill instructions now reach an agent running outside
opencode's permission system**. Key implications:

- Skill content is copied to disk and loaded by Cursor's own agent runtime —
opencode cannot intervene, revoke, or gate access once the files are written.
- Skills are discovered from all standard locations (project `.opencode/skills/`,
`.claude/skills/`, `.agents/skills/`, global `~/.config/opencode/skills/`,
etc.) **and** from `config.skills.paths` — additional directories configured
in `opencode.json`. If `skills.paths` points to directories outside the
project, skills from those directories will also be mirrored.
- `deny`-permissioned skills are excluded from the mirror before writing.
- `ask`-permissioned skills are also excluded, because the ask prompt cannot be
enforced across the Cursor boundary (there is no way for Cursor to relay the
approval request back to opencode's permission system).
- The mirror is written at plugin init and re-synced each turn, so permission
changes take effect on the next turn (not retroactively on already-loaded
skills within a running Cursor agent).
- A user-owned `.cursor/skills/<id>/SKILL.md` (without the `generated:
opencode-cursor` sentinel) is never overwritten or deleted — the plugin
respects existing user content.
- `config.skills.urls` (HTTP skill catalogs) are not mirrored — only filesystem
paths are scanned.

## Credentials

- Your `CURSOR_API_KEY` is read from opencode auth storage or the environment.
Expand Down
171 changes: 171 additions & 0 deletions scripts/verify-skill-mirror.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
// Standalone verification: exercises the full skill discovery + mirror pipeline
// against the real skills on this machine, without needing opencode or a Cursor
// API key. Read-only against real skills; writes only to a temp dir.
//
// Run: npx tsx scripts/verify-skill-mirror.mjs
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { homedir } from "node:os";

const { discoverSkills, resolveSkills, skillSetHash } = await import(
"../src/plugin/skill-discovery.ts"
);
const { writeSkillMirror, removeSkillMirror, buildSkillsCatalogue } = await import(
"../src/provider/skill-mirror.ts"
);

// Read skills.paths from the real opencode config.
const configPath = join(homedir(), ".config", "opencode", "opencode.jsonc");
const configRaw = readFileSync(configPath, "utf8");
// Strip JSONC comments — but only // that aren't inside strings.
// Simple approach: remove // comments and /* */ comments, but preserve
// // inside quoted strings by tracking quote state.
function stripJsonc(text) {
let result = "";
let inString = false;
let i = 0;
while (i < text.length) {
const ch = text[i];
if (inString) {
result += ch;
if (ch === "\\" && i + 1 < text.length) {
result += text[i + 1];
i += 2;
continue;
}
if (ch === '"') inString = false;
i++;
continue;
}
if (ch === '"') {
inString = true;
result += ch;
i++;
continue;
}
if (ch === "/" && text[i + 1] === "/") {
// Line comment — skip to end of line.
while (i < text.length && text[i] !== "\n") i++;
continue;
}
if (ch === "/" && text[i + 1] === "*") {
// Block comment — skip to closing.
i += 2;
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
i += 2;
continue;
}
result += ch;
i++;
}
return result;
}
const config = JSON.parse(stripJsonc(configRaw));
const extraPaths = config.skills?.paths ?? [];

console.log("=== Skill Mirror Verification ===\n");
console.log(`Config skills.paths: ${extraPaths.length} entries`);
for (const p of extraPaths) console.log(` - ${p}`);
console.log();

// Use the opencode config dir as cwd (where opencode actually runs).
const cwd = join(homedir(), ".config", "opencode");
console.log(`Discovery cwd: ${cwd}`);

// Layer 1: Discover skills (with extraPaths from config).
const discovered = discoverSkills(cwd, extraPaths);
console.log(`\nDiscovered: ${discovered.length} skills`);
for (const s of discovered.sort((a, b) => a.id.localeCompare(b.id))) {
console.log(` - ${s.id}: ${s.description.slice(0, 80)}${s.description.length > 80 ? "…" : ""}`);
}

// Layer 2: Filter through permissions.
const resolved = resolveSkills(cwd, config, undefined);
console.log(`\nAfter permission filtering: ${resolved.skills.length} permitted, ${resolved.withheld.length} withheld`);
for (const w of resolved.withheld) {
console.log(` WITHHELD: ${w.id} — ${w.reason}`);
}

// Layer 3: Materialise to a temp dir.
const tmpDir = mkdtempSync(join(tmpdir(), "skill-mirror-verify-"));
console.log(`\nMirror target: ${tmpDir}`);
const warnings = [];
const writeResult = writeSkillMirror(tmpDir, resolved.skills, (msg) => warnings.push(msg));
console.log(`Write result: ${writeResult}`);
if (warnings.length > 0) {
console.log(`Warnings (${warnings.length}):`);
for (const w of warnings) console.log(` - ${w}`);
}

// Layer 4: Verify the mirror on disk.
const mirrorDir = join(tmpDir, ".cursor", "skills");
const mirrored = existsSync(mirrorDir) ? readdirSync(mirrorDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name) : [];
console.log(`\nMirrored directories: ${mirrored.length}`);
for (const id of mirrored.sort()) {
const skillMd = join(mirrorDir, id, "SKILL.md");
const content = readFileSync(skillMd, "utf8");
const hasSentinel = content.includes("generated: opencode-cursor");
console.log(` ${id}: sentinel=${hasSentinel}`);
}

// Layer 5: Verify .gitignore.
const gitignorePath = join(mirrorDir, ".gitignore");
if (existsSync(gitignorePath)) {
const gi = readFileSync(gitignorePath, "utf8");
console.log(`\n.gitignore entries: ${gi.split(/\r?\n/).filter(Boolean).length}`);
console.log(` Contains .gitignore itself: ${gi.includes(".gitignore")}`);
}

// Layer 6: Verify the catalogue.
const catalogue = buildSkillsCatalogue(resolved.skills);
if (catalogue) {
console.log(`\nCatalogue length: ${catalogue.length} chars`);
console.log(` Contains <available_skills>: ${catalogue.includes("<available_skills>")}`);
console.log(` Contains </available_skills>: ${catalogue.includes("</available_skills>")}`);
const skillIdsInCatalogue = resolved.skills.filter((s) => catalogue.includes(`**${s.id}**`));
console.log(` Skills listed in catalogue: ${skillIdsInCatalogue.length} / ${resolved.skills.length}`);
}

// Layer 7: Verify idempotency.
const hash1 = skillSetHash(resolved.skills);
const hash2 = skillSetHash(resolved.skills);
console.log(`\nIdempotency: hash stable = ${hash1 === hash2}`);

// Layer 8: Verify dispose.
removeSkillMirror(tmpDir);
const afterDispose = existsSync(mirrorDir) ? readdirSync(mirrorDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name) : [];
console.log(`\nAfter removeSkillMirror: ${afterDispose.length} dirs remain`);

// Cleanup.
rmSync(tmpDir, { recursive: true, force: true });

// Summary.
console.log("\n=== Summary ===");
console.log(`Discovered: ${discovered.length}`);
console.log(`Permitted: ${resolved.skills.length}`);
console.log(`Withheld: ${resolved.withheld.length}`);
console.log(`Mirrored: ${mirrored.length}`);
console.log(`Warnings: ${warnings.length}`);
console.log(`Dispose clean: ${afterDispose.length === 0}`);

const allPass = discovered.length > 0
&& resolved.skills.length === discovered.length
&& mirrored.length === resolved.skills.length
&& warnings.length === 0
&& afterDispose.length === 0;

console.log(`\nResult: ${allPass ? "PASS" : "FAIL"}`);
if (!allPass) {
console.log(" Discrepancies:");
if (discovered.length === 0) console.log(" - No skills discovered");
if (resolved.skills.length !== discovered.length) console.log(` - Permission filtering changed count: ${discovered.length} -> ${resolved.skills.length}`);
if (mirrored.length !== resolved.skills.length) console.log(` - Mirror count mismatch: expected ${resolved.skills.length}, got ${mirrored.length}`);
if (warnings.length > 0) console.log(` - ${warnings.length} warnings during mirror`);
if (afterDispose.length > 0) console.log(` - Dispose left ${afterDispose.length} dirs`);
}
process.exit(allPass ? 0 : 1);
27 changes: 16 additions & 11 deletions src/plugin/cursor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,17 +181,22 @@ export function buildCursorTools(deps: CursorToolDeps): Record<string, ToolDefin
let result;
try {
const baseCwd = args.cwd ?? context.directory ?? deps.defaultCwd();
result = await runDelegate({
apiKey,
prompt: args.prompt,
model: args.model,
cwd: args.additionalCwds?.length ? [baseCwd, ...args.additionalCwds] : baseCwd,
...(args.mode ? { mode: args.mode } : {}),
...(args.thinking ? { thinking: args.thinking } : {}),
...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}),
...(args.agentId ? { agentId: args.agentId } : {}),
abortSignal: context.abort,
});
result = await runDelegate({
apiKey,
prompt: args.prompt,
model: args.model,
cwd: args.additionalCwds?.length ? [baseCwd, ...args.additionalCwds] : baseCwd,
// Honour the user's project settings layer so delegated turns pick
// up `.cursor/skills/` from the delegate's cwd. The delegate defaults
// to ["project"] on its own, so this is only needed when the user
// explicitly configured settingSources on the provider.
settingSources: ["project"],
...(args.mode ? { mode: args.mode } : {}),
...(args.thinking ? { thinking: args.thinking } : {}),
...(args.sandbox !== undefined ? { sandbox: args.sandbox } : {}),
...(args.agentId ? { agentId: args.agentId } : {}),
abortSignal: context.abort,
});
} catch (err) {
return `Delegation failed: ${errorMessage(err)}`;
}
Expand Down
Loading