Skip to content
Merged
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
385 changes: 383 additions & 2 deletions apps/hook/server/index.ts

Large diffs are not rendered by default.

14 changes: 14 additions & 0 deletions apps/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,19 @@ Default config:
}
```

Runtime selection is automatic. In Bun-hosted OpenCode, Plannotator uses the embedded server bundled with the plugin. In Node-hosted or wrapped OpenCode environments, the plugin falls back to the installed `plannotator` CLI and sends the result back through OpenCode. You can force the fallback while debugging:

```json
{
"$schema": "https://opencode.ai/config.json",
"plugin": [
["@plannotator/opencode@latest", {
"runtime": "cli"
}]
]
}
```

If you use other OpenCode plugins, keep everything in one `plugin` array and attach Plannotator's options directly to the Plannotator entry:

```json
Expand Down Expand Up @@ -144,6 +157,7 @@ Register the tool but manage prompts and permissions yourself:
| `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. |
| `PLANNOTATOR_PASTE_URL` | Custom paste service URL for self-hosting. Default: `https://plannotator-paste.plannotator.workers.dev`. |
| `PLANNOTATOR_PLAN_TIMEOUT_SECONDS` | Timeout for `submit_plan` review wait. Default: `345600` (96h). Set `0` to disable timeout. |
| `PLANNOTATOR_BIN` | Override the CLI path used by the OpenCode plugin's CLI runtime fallback. Default: `plannotator` on `PATH`. |

## Devcontainer / Docker

Expand Down
161 changes: 161 additions & 0 deletions apps/opencode-plugin/cli-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { describe, expect, mock, test } from "bun:test";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import {
buildAnnotateCliArgs,
buildCliBridgeEnv,
buildCliSpawnConfig,
buildReviewPromptFromBridgeOutcome,
formatUserFacingCliStderrLine,
getRecentAssistantMessages,
} from "./cli-bridge";

describe("OpenCode CLI bridge helpers", () => {
test("maps OpenCode sharing context into child CLI env", () => {
expect(buildCliBridgeEnv({
sharingEnabled: false,
shareBaseUrl: "https://share.example.test",
pasteApiUrl: "https://paste.example.test",
})).toEqual({
PLANNOTATOR_SHARE: "disabled",
PLANNOTATOR_SHARE_URL: "https://share.example.test",
PLANNOTATOR_PASTE_URL: "https://paste.example.test",
});

expect(buildCliBridgeEnv({ sharingEnabled: true })).toEqual({
PLANNOTATOR_SHARE: "enabled",
});
});

test("builds annotate CLI args without folding flags into the path", () => {
const args = buildAnnotateCliArgs({
filePath: "https://example.com/docs",
rawFilePath: "https://example.com/docs",
gate: true,
json: false,
hook: false,
renderHtml: true,
noJina: true,
});

expect(args).toEqual([
"annotate",
"https://example.com/docs",
"--json",
"--gate",
"--render-html",
"--no-jina",
]);
});

test("surfaces remote share-link stderr lines and ignores noisy stderr", () => {
expect(formatUserFacingCliStderrLine(" Open this link on your local machine to review the plan:")).toBe(
"Open this link on your local machine to review the plan:",
);
expect(formatUserFacingCliStderrLine(" https://share.plannotator.ai/#abc")).toBe(
"https://share.plannotator.ai/#abc",
);
expect(formatUserFacingCliStderrLine(" (1.2 KB - plan only, annotations added in browser)")).toBe(
"(1.2 KB - plan only, annotations added in browser)",
);
expect(formatUserFacingCliStderrLine("Fetching: https://example.com")).toBeUndefined();
});

test("resolves Windows CLI commands to an executable without shell mode", () => {
const dir = mkdtempSync(path.join(tmpdir(), "plannotator-cli-"));
try {
const exe = path.join(dir, "plannotator.exe");
writeFileSync(exe, "");

const config = buildCliSpawnConfig(
"plannotator",
["annotate", "my notes.md", "--json"],
"win32",
{
PATH: dir,
PATHEXT: ".COM;.EXE;.BAT;.CMD",
},
);

expect(config).toEqual({
command: exe,
args: ["annotate", "my notes.md", "--json"],
shell: false,
});
} finally {
rmSync(dir, { recursive: true, force: true });
}
});

test("collects recent assistant messages newest-first with ids and timestamps", async () => {
const client = {
session: {
messages: mock(async () => ({
data: [
{
info: { role: "assistant", id: "old", time: { created: 1_700_000_000_000 } },
parts: [{ type: "text", text: "Old" }],
},
{
info: { role: "user", id: "user" },
parts: [{ type: "text", text: "Ignore me" }],
},
{
info: { role: "assistant", id: "latest", time: { created: 1_700_000_001_000 } },
parts: [{ type: "text", text: "Latest" }],
},
],
})),
},
};

const messages = await getRecentAssistantMessages(client, "session-1");

expect(messages).toEqual([
{
messageId: "latest",
text: "Latest",
timestamp: new Date(1_700_000_001_000).toISOString(),
},
{
messageId: "old",
text: "Old",
timestamp: new Date(1_700_000_000_000).toISOString(),
},
]);
});

test("formats structured review outcomes for OpenCode prompt injection", () => {
expect(buildReviewPromptFromBridgeOutcome({
decision: "dismissed",
})).toEqual({ message: null });

const approved = buildReviewPromptFromBridgeOutcome({
decision: "approved",
approved: true,
agentSwitch: "build",
});
expect(approved.agent).toBe("build");
expect(approved.message).toContain("Code Review");

const localFeedback = buildReviewPromptFromBridgeOutcome({
decision: "annotated",
approved: false,
isPRMode: false,
feedback: "Fix these issues.",
agentSwitch: "disabled",
});
expect(localFeedback.agent).toBeUndefined();
expect(localFeedback.message).toContain("Fix these issues.");
expect(localFeedback.message).toContain("Please address this feedback.");

const prFeedback = buildReviewPromptFromBridgeOutcome({
decision: "annotated",
approved: false,
isPRMode: true,
feedback: "PR comment only.",
});
expect(prFeedback.message).toBe("PR comment only.");
});
});
Loading
Loading