scroll-plugin 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,36 @@
1
+ # Scroll Plugin (OpenCode)
2
+
3
+ Unified OpenCode plugin for Scroll that handles both prompt capture (for RLM feedback) and inline skill/prompt suggestions.
4
+
5
+ ## Requirements
6
+ - Bun runtime (tested with 1.3+)
7
+ - `scroll` CLI available in PATH (or set `SCROLL_BIN`)
8
+
9
+ ## Setup
10
+ 1. Copy the plugin into OpenCode plugins:
11
+ ```bash
12
+ mkdir -p ~/.config/opencode/plugin
13
+ cp -r scroll-plugin ~/.config/opencode/plugin/
14
+ ```
15
+ 2. Ensure `opencode.json` includes the plugin:
16
+ ```json
17
+ {
18
+ "plugin": ["scroll-plugin"],
19
+ "mcp": { "scroll": { "type": "local", "command": ["scroll", "mcp", "start"], "enabled": true } }
20
+ }
21
+ ```
22
+ (running `scroll init --opencode` will add this automatically.)
23
+
24
+ ## Environment
25
+ - `SCROLL_BIN`: override scroll binary path
26
+ - `SCROLL_SUGGEST_LIMIT`: suggestions per request (default 3)
27
+ - `SCROLL_SUGGEST_DEBOUNCE_MS`: debounce for TUI suggestions (default 800ms)
28
+ - `SCROLL_SUGGEST_PROVISION`: set `0` to skip MCP provisioning
29
+ - `SCROLL_PLUGIN_DRY_RUN`: set `1` to disable spawning scroll (useful for tests)
30
+
31
+ ## Dev
32
+ ```bash
33
+ bun install
34
+ bun run lint
35
+ bun test
36
+ ```
package/biome.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://biomejs.dev/schemas/1.7.3/schema.json",
3
+ "formatter": {
4
+ "enabled": true
5
+ },
6
+ "linter": {
7
+ "enabled": true
8
+ }
9
+ }
package/bun.lock ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "scroll-plugin",
7
+ "devDependencies": {
8
+ "@biomejs/biome": "^1.7.3",
9
+ },
10
+ },
11
+ },
12
+ "packages": {
13
+ "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
14
+
15
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="],
16
+
17
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="],
18
+
19
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="],
20
+
21
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="],
22
+
23
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="],
24
+
25
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="],
26
+
27
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="],
28
+
29
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="],
30
+ }
31
+ }
package/index.js ADDED
@@ -0,0 +1,151 @@
1
+ // Scroll OpenCode plugin: capture + suggest combined
2
+
3
+ export const ScrollPlugin = async ({ client, directory }) => {
4
+ const CONFIG = {
5
+ scrollBin: process.env.SCROLL_BIN || "scroll",
6
+ suggestLimit: Number.parseInt(process.env.SCROLL_SUGGEST_LIMIT || "3", 10),
7
+ debounceMs: Number.parseInt(
8
+ process.env.SCROLL_SUGGEST_DEBOUNCE_MS || "800",
9
+ 10,
10
+ ),
11
+ provision: (process.env.SCROLL_SUGGEST_PROVISION || "1") === "1",
12
+ dryRun: (process.env.SCROLL_PLUGIN_DRY_RUN || "0") === "1",
13
+ };
14
+
15
+ let promptCaptured = false;
16
+ let lastInput = "";
17
+ let lastInjected = "";
18
+ let timer = null;
19
+ let runId = 0;
20
+
21
+ const log = async (level, message, extra = {}) => {
22
+ try {
23
+ if (client?.app?.log) {
24
+ await client.app.log({
25
+ service: "scroll-plugin",
26
+ level,
27
+ message,
28
+ extra,
29
+ });
30
+ } else {
31
+ console.log(`[scroll-plugin:${level}]`, message, extra);
32
+ }
33
+ } catch {
34
+ /* logging should never break the plugin */
35
+ }
36
+ };
37
+
38
+ const execScroll = async (args, env = {}) => {
39
+ if (CONFIG.dryRun) return "";
40
+
41
+ const scrollEnv = { ...process.env, ...env, SCROLL_SOURCE: "opencode" };
42
+
43
+ const proc = Bun.spawn([CONFIG.scrollBin, ...args], {
44
+ env: scrollEnv,
45
+ stdout: "pipe",
46
+ stderr: "pipe",
47
+ });
48
+
49
+ const stdout = await new Response(proc.stdout).text();
50
+ const stderr = await new Response(proc.stderr).text();
51
+
52
+ if (proc.exitCode !== 0) {
53
+ throw new Error(stderr || `scroll exited with code ${proc.exitCode}`);
54
+ }
55
+
56
+ return stdout.trim();
57
+ };
58
+
59
+ const scheduleSuggest = (text) => {
60
+ lastInput = text;
61
+ runId += 1;
62
+ const current = runId;
63
+
64
+ if (timer) clearTimeout(timer);
65
+
66
+ timer = setTimeout(() => {
67
+ void (async () => {
68
+ const prompt = lastInput.trim();
69
+ if (!prompt) return;
70
+ if (current !== runId) return;
71
+ if (prompt === lastInjected) return;
72
+
73
+ await log("debug", "running scroll suggest", {
74
+ cwd: directory,
75
+ prompt_len: prompt.length,
76
+ });
77
+
78
+ try {
79
+ const args = [
80
+ "suggest",
81
+ "skill",
82
+ prompt,
83
+ "--limit",
84
+ CONFIG.suggestLimit.toString(),
85
+ ];
86
+ if (CONFIG.provision) args.push("--provision");
87
+
88
+ const suggestions = await execScroll(args);
89
+ if (!suggestions) return;
90
+ if (current !== runId) return;
91
+
92
+ lastInjected = prompt;
93
+ await client.tui.appendPrompt({
94
+ body: { text: `\n\n${suggestions}\n` },
95
+ });
96
+ } catch (e) {
97
+ await log("warn", "scroll suggest failed", { error: String(e) });
98
+ }
99
+ })();
100
+ }, CONFIG.debounceMs);
101
+ };
102
+
103
+ return {
104
+ "message.part.updated": async (input) => {
105
+ const role = input?.role;
106
+ const content = input?.content ?? "";
107
+
108
+ if (role === "user" && content?.trim() && !promptCaptured) {
109
+ promptCaptured = true;
110
+ try {
111
+ await execScroll(["capture", "hook", "prompt"], { PROMPT: content });
112
+ } catch (e) {
113
+ await log("warn", "capture prompt failed", { error: String(e) });
114
+ promptCaptured = false;
115
+ }
116
+ }
117
+ },
118
+
119
+ "message.updated": async (input) => {
120
+ const role = input?.role;
121
+ const content = input?.content ?? "";
122
+
123
+ if (role === "assistant" && content?.trim() && promptCaptured) {
124
+ try {
125
+ await execScroll(["capture", "hook", "response"], {
126
+ CLAUDE_RESPONSE: content,
127
+ });
128
+ } catch (e) {
129
+ await log("warn", "capture response failed", { error: String(e) });
130
+ }
131
+ promptCaptured = false;
132
+ }
133
+ },
134
+
135
+ "session.created": async (input) => {
136
+ await log("info", "session created", {
137
+ sessionId: input?.session?.id ?? "unknown",
138
+ isSubagent: input?.session?.parentId != null,
139
+ cwd: directory,
140
+ });
141
+ },
142
+
143
+ "tui.prompt.append": async (input) => {
144
+ const text = input?.text ?? "";
145
+ if (!text.trim()) return;
146
+ scheduleSuggest(text);
147
+ },
148
+ };
149
+ };
150
+
151
+ export default ScrollPlugin;
package/index.test.js ADDED
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import ScrollPlugin from "./index.js";
3
+
4
+ describe("ScrollPlugin", () => {
5
+ it("exposes handlers without executing scroll in dry-run", async () => {
6
+ process.env.SCROLL_PLUGIN_DRY_RUN = "1";
7
+
8
+ const appLogCalls = [];
9
+ const promptAppends = [];
10
+
11
+ const client = {
12
+ app: {
13
+ log: ({ level, message, extra }) =>
14
+ appLogCalls.push({ level, message, extra }),
15
+ },
16
+ tui: { appendPrompt: ({ body }) => promptAppends.push(body?.text ?? "") },
17
+ };
18
+
19
+ const plugin = await ScrollPlugin({ client, directory: "/tmp" });
20
+
21
+ expect(typeof plugin["message.part.updated"]).toBe("function");
22
+ expect(typeof plugin["message.updated"]).toBe("function");
23
+ expect(typeof plugin["tui.prompt.append"]).toBe("function");
24
+
25
+ await plugin["message.part.updated"]({ role: "user", content: "hello" });
26
+ await plugin["message.updated"]({ role: "assistant", content: "hi" });
27
+ await plugin["tui.prompt.append"]({ text: "prompt text" });
28
+
29
+ expect(promptAppends.length).toBeGreaterThanOrEqual(0);
30
+ expect(appLogCalls.length).toBeGreaterThanOrEqual(0);
31
+ });
32
+ });
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "scroll-plugin",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for Scroll: capture + suggest in one module",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "license": "MIT",
8
+ "scripts": {
9
+ "lint": "bunx biome check .",
10
+ "test": "bun test --no-bail"
11
+ },
12
+ "devDependencies": {
13
+ "@biomejs/biome": "^1.7.3"
14
+ }
15
+ }