Skip to content

Commit 4c7a2cd

Browse files
committed
Add OpenCode 2 plugin entrypoint alongside V1 server hook
V2 rejects the V1 function export, so the default export now carries {id, setup} for OpenCode 2 plus server() for OpenCode 1. setup registers the opencode_sync tool via ctx.tool.transform and subscribes to session.idle via ctx.event.subscribe, mirroring the V1 behavior. Also teach prunePluginCache the V2 plugins key (string and {package} specs) while keeping the legacy plugin key, and update the install docs. Covers both halves and the new pruning paths with tests/plugin.test.ts.
1 parent f9b2c20 commit 4c7a2cd

5 files changed

Lines changed: 489 additions & 40 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ Plus the boring things that turn out to matter:
7171
// ~/.config/opencode/opencode.json
7272
{
7373
"$schema": "https://opencode.ai/config.json",
74-
"plugin": ["opencode-github-sync"]
74+
"plugins": ["opencode-github-sync"]
7575
}
7676
```
7777

‎README.zh-CN.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ opencode-sync push
6969
// ~/.config/opencode/opencode.json
7070
{
7171
"$schema": "https://opencode.ai/config.json",
72-
"plugin": ["opencode-github-sync"]
72+
"plugins": ["opencode-github-sync"]
7373
}
7474
```
7575

‎src/core/stage.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,15 @@ export function prunePluginCache(configRoot: string, cacheRoot: string): number
299299
return 0;
300300
}
301301

302-
const enabled = new Set<string>((config?.plugin ?? []).map(pluginSpecToName));
302+
const raw: unknown = (config as any)?.plugins ?? (config as any)?.plugin ?? [];
303+
const specs = (Array.isArray(raw) ? raw : []).flatMap((entry) => {
304+
if (typeof entry === "string") return [entry];
305+
if (entry && typeof entry === "object" && typeof (entry as any).package === "string") {
306+
return [(entry as any).package as string];
307+
}
308+
return [];
309+
});
310+
const enabled = new Set<string>(specs.map(pluginSpecToName));
303311
const pkg = readJson(cachePackage);
304312
if (!pkg) return 0;
305313

‎src/plugin/index.ts‎

Lines changed: 187 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { loadSettings, repoUrl, settingsPath } from "../core/settings.js";
66
import { pull, push, status } from "../core/sync.js";
77

88
/**
9-
* OpenCode plugin entry point.
9+
* OpenCode plugin entry point (dual V1 + V2).
1010
*
1111
* The plugin is a convenience layer, never the only way in. It runs inside the
1212
* OpenCode process, which means it cannot help when the configuration it just
@@ -19,7 +19,15 @@ import { pull, push, status } from "../core/sync.js";
1919
* - an `opencode_sync` tool, so syncing can be asked for in plain language
2020
*
2121
* Everything is off unless the user configured a repository, and every failure
22-
* is reported as a toast instead of taking OpenCode down with it.
22+
* is reported as a toast (V1) or a console line (V2) instead of taking
23+
* OpenCode down with it.
24+
*
25+
* V1 (OpenCode 1.x) calls the default export as a function and consumes the
26+
* returned `{ event, tool }` hook map. V2 (OpenCode 2.x) decodes the default
27+
* export for an `{ id, setup }` definition and calls `setup(ctx)`, which
28+
* registers the same capabilities through `ctx.tool.transform` and
29+
* `ctx.event.subscribe`. One default export carries both halves; the two
30+
* implementations are independent and share only the core helpers below.
2331
*/
2432

2533
type ToastVariant = "info" | "success" | "warning" | "error";
@@ -29,6 +37,25 @@ interface PluginContext {
2937
directory?: string;
3038
}
3139

40+
/** Minimal structural slice of the V2 plugin context this file touches. */
41+
interface V2ToolEditor {
42+
add(tool: {
43+
name: string;
44+
description: string;
45+
input: unknown;
46+
execute: (input: any) => Promise<{ content: string }>;
47+
}): void;
48+
}
49+
50+
interface V2Context {
51+
tool: {
52+
transform: (callback: (editor: V2ToolEditor) => void) => Promise<unknown>;
53+
};
54+
event: {
55+
subscribe: (options?: { signal?: AbortSignal }) => AsyncIterable<{ type: string }>;
56+
};
57+
}
58+
3259
async function toast(client: any, message: string, variant: ToastVariant): Promise<void> {
3360
try {
3461
await client?.tui?.showToast?.({ body: { message, variant } });
@@ -47,6 +74,17 @@ async function log(client: any, level: string, message: string, extra?: unknown)
4774
}
4875
}
4976

77+
/** V2 equivalent of log/toast: the promise context has no app.log or TUI client. */
78+
function notice(level: "info" | "warn", message: string, extra?: unknown): void {
79+
if (level === "warn") {
80+
if (extra !== undefined) console.warn(`[opencode-github-sync] ${message}`, extra);
81+
else console.warn(`[opencode-github-sync] ${message}`);
82+
} else {
83+
if (extra !== undefined) console.log(`[opencode-github-sync] ${message}`, extra);
84+
else console.log(`[opencode-github-sync] ${message}`);
85+
}
86+
}
87+
5088
function isConfigured(): boolean {
5189
const roots = getRoots();
5290
if (!fs.existsSync(settingsPath(roots.config))) return false;
@@ -81,6 +119,29 @@ async function runPull(client: any, announce: boolean): Promise<void> {
81119
}
82120
}
83121

122+
async function runPullV2(announce: boolean): Promise<void> {
123+
const roots = getRoots();
124+
const reporter = new CollectingReporter();
125+
try {
126+
const result = await withSyncLock(roots.config, { waitMs: 30_000 }, () =>
127+
pull({ reporter, roots }),
128+
);
129+
notice("info", `pull: ${result.message}`, { summary: result.summary });
130+
if (result.changed) {
131+
notice(
132+
"info",
133+
`Config updated from GitHub (${result.files.length} file(s)). Restart OpenCode to apply.`,
134+
);
135+
} else if (announce) {
136+
notice("info", "Config already up to date.");
137+
}
138+
} catch (error) {
139+
const message = (error as Error).message;
140+
notice("warn", `pull failed: ${message}`);
141+
if (announce) notice("warn", `Sync pull failed: ${message}`);
142+
}
143+
}
144+
84145
async function runPush(client: any, announce: boolean): Promise<void> {
85146
const roots = getRoots();
86147
const reporter = new CollectingReporter();
@@ -99,6 +160,71 @@ async function runPush(client: any, announce: boolean): Promise<void> {
99160
}
100161
}
101162

163+
async function runPushV2(announce: boolean): Promise<void> {
164+
const roots = getRoots();
165+
const reporter = new CollectingReporter();
166+
try {
167+
const result = await withSyncLock(roots.config, { waitMs: 30_000 }, () =>
168+
push({ reporter, roots }),
169+
);
170+
notice("info", `push: ${result.message}`);
171+
if (announce) notice("info", result.message);
172+
} catch (error) {
173+
const message = (error as Error).message;
174+
notice("warn", `push failed: ${message}`);
175+
if (announce) notice("warn", `Sync push failed: ${message}`);
176+
}
177+
}
178+
179+
const SYNC_TOOL_DESCRIPTION =
180+
"Sync OpenCode configuration with the GitHub sync repository. " +
181+
"Use action 'push' to upload this machine's configuration, 'pull' to apply the shared " +
182+
"configuration, or 'status' to report what is out of sync.";
183+
184+
const SYNC_TOOL_INPUT = {
185+
type: "object",
186+
properties: {
187+
action: {
188+
type: "string",
189+
enum: ["push", "pull", "status"],
190+
description: "Which sync operation to run.",
191+
},
192+
},
193+
additionalProperties: false,
194+
} as const;
195+
196+
async function executeSyncAction(args: { action?: string }): Promise<string> {
197+
const roots = getRoots();
198+
const action = args?.action ?? "status";
199+
const reporter = new CollectingReporter();
200+
201+
if (action === "status") {
202+
const state = status({ roots });
203+
return JSON.stringify(state, null, 2);
204+
}
205+
206+
const result = await withSyncLock(roots.config, { waitMs: 60_000 }, () =>
207+
action === "push" ? push({ reporter, roots }) : pull({ reporter, roots }),
208+
);
209+
210+
const lines = [result.message];
211+
if (result.files.length > 0) {
212+
lines.push(
213+
`Files: ${result.files
214+
.slice(0, 20)
215+
.map((f) => `${f.kind[0]} ${f.path}`)
216+
.join(", ")}`,
217+
);
218+
}
219+
if (result.restartRequired && result.changed) {
220+
lines.push("Restart OpenCode for the new configuration to take effect.");
221+
}
222+
if (reporter.lines.length > 0) {
223+
lines.push(...reporter.lines.filter((l) => l.level === "warn").map((l) => `! ${l.message}`));
224+
}
225+
return lines.join("\n");
226+
}
227+
102228
export const OpencodeGithubSync = async (ctx: PluginContext) => {
103229
const client = ctx?.client;
104230
const roots = getRoots();
@@ -129,10 +255,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => {
129255

130256
tool: {
131257
opencode_sync: {
132-
description:
133-
"Sync OpenCode configuration with the GitHub sync repository. " +
134-
"Use action 'push' to upload this machine's configuration, 'pull' to apply the shared " +
135-
"configuration, or 'status' to report what is out of sync.",
258+
description: SYNC_TOOL_DESCRIPTION,
136259
args: {
137260
action: {
138261
type: "string",
@@ -141,40 +264,67 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => {
141264
},
142265
},
143266
async execute(args: { action?: string }) {
144-
const action = args?.action ?? "status";
145-
const reporter = new CollectingReporter();
146-
147-
if (action === "status") {
148-
const state = status({ roots });
149-
return JSON.stringify(state, null, 2);
150-
}
151-
152-
const result = await withSyncLock(roots.config, { waitMs: 60_000 }, () =>
153-
action === "push" ? push({ reporter, roots }) : pull({ reporter, roots }),
154-
);
155-
156-
const lines = [result.message];
157-
if (result.files.length > 0) {
158-
lines.push(
159-
`Files: ${result.files
160-
.slice(0, 20)
161-
.map((f) => `${f.kind[0]} ${f.path}`)
162-
.join(", ")}`,
163-
);
164-
}
165-
if (result.restartRequired && result.changed) {
166-
lines.push("Restart OpenCode for the new configuration to take effect.");
167-
}
168-
if (reporter.lines.length > 0) {
169-
lines.push(
170-
...reporter.lines.filter((l) => l.level === "warn").map((l) => `! ${l.message}`),
171-
);
172-
}
173-
return lines.join("\n");
267+
return executeSyncAction(args);
174268
},
175269
},
176270
},
177271
};
178272
};
179273

180-
export default OpencodeGithubSync;
274+
async function setup(ctx: V2Context): Promise<() => void> {
275+
const controller = new AbortController();
276+
const roots = getRoots();
277+
278+
if (!isConfigured()) {
279+
notice(
280+
"info",
281+
"opencode-github-sync is installed but no repository is configured. Run `opencode-sync init`.",
282+
);
283+
return () => controller.abort();
284+
}
285+
286+
const settings = loadSettings(roots.config);
287+
288+
if (settings.autoPullOnStartup) {
289+
// Deliberately not awaited: OpenCode should finish starting even when the
290+
// network is slow or GitHub is unreachable.
291+
void runPullV2(false);
292+
}
293+
294+
await ctx.tool.transform((editor) => {
295+
editor.add({
296+
name: "opencode_sync",
297+
description: SYNC_TOOL_DESCRIPTION,
298+
input: SYNC_TOOL_INPUT,
299+
async execute(input) {
300+
return { content: await executeSyncAction(input as { action?: string }) };
301+
},
302+
});
303+
});
304+
305+
if (settings.autoPushOnIdle) {
306+
void (async () => {
307+
try {
308+
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
309+
if (event.type === "session.idle") {
310+
void runPushV2(false);
311+
}
312+
}
313+
} catch {
314+
// Aborted on unload — nothing to report.
315+
}
316+
})();
317+
}
318+
319+
return () => controller.abort();
320+
}
321+
322+
const V2Plugin = {
323+
id: "opencode-github-sync",
324+
setup,
325+
};
326+
327+
export default {
328+
...V2Plugin,
329+
server: OpencodeGithubSync,
330+
};

0 commit comments

Comments
 (0)