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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ Plus the boring things that turn out to matter:
// ~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-github-sync"]
"plugins": ["opencode-github-sync"]
}
```

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ opencode-sync push
// ~/.config/opencode/opencode.json
{
"$schema": "https://opencode.ai/config.json",
"plugin": ["opencode-github-sync"]
"plugins": ["opencode-github-sync"]
}
```

Expand Down
10 changes: 9 additions & 1 deletion src/core/stage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,15 @@ export function prunePluginCache(configRoot: string, cacheRoot: string): number
return 0;
}

const enabled = new Set<string>((config?.plugin ?? []).map(pluginSpecToName));
const raw: unknown = (config as any)?.plugins ?? (config as any)?.plugin ?? [];
const specs = (Array.isArray(raw) ? raw : []).flatMap((entry) => {
if (typeof entry === "string") return [entry];
if (entry && typeof entry === "object" && typeof (entry as any).package === "string") {
return [(entry as any).package as string];
}
return [];
});
const enabled = new Set<string>(specs.map(pluginSpecToName));
const pkg = readJson(cachePackage);
if (!pkg) return 0;

Expand Down
224 changes: 187 additions & 37 deletions src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { loadSettings, repoUrl, settingsPath } from "../core/settings.js";
import { pull, push, status } from "../core/sync.js";

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

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

/** Minimal structural slice of the V2 plugin context this file touches. */
interface V2ToolEditor {
add(tool: {
name: string;
description: string;
input: unknown;
execute: (input: any) => Promise<{ content: string }>;
}): void;
}

interface V2Context {
tool: {
transform: (callback: (editor: V2ToolEditor) => void) => Promise<unknown>;
};
event: {
subscribe: (options?: { signal?: AbortSignal }) => AsyncIterable<{ type: string }>;
};
}

async function toast(client: any, message: string, variant: ToastVariant): Promise<void> {
try {
await client?.tui?.showToast?.({ body: { message, variant } });
Expand All @@ -47,6 +74,17 @@ async function log(client: any, level: string, message: string, extra?: unknown)
}
}

/** V2 equivalent of log/toast: the promise context has no app.log or TUI client. */
function notice(level: "info" | "warn", message: string, extra?: unknown): void {
if (level === "warn") {
if (extra !== undefined) console.warn(`[opencode-github-sync] ${message}`, extra);
else console.warn(`[opencode-github-sync] ${message}`);
} else {
if (extra !== undefined) console.log(`[opencode-github-sync] ${message}`, extra);
else console.log(`[opencode-github-sync] ${message}`);
}
}

function isConfigured(): boolean {
const roots = getRoots();
if (!fs.existsSync(settingsPath(roots.config))) return false;
Expand Down Expand Up @@ -81,6 +119,29 @@ async function runPull(client: any, announce: boolean): Promise<void> {
}
}

async function runPullV2(announce: boolean): Promise<void> {
const roots = getRoots();
const reporter = new CollectingReporter();
try {
const result = await withSyncLock(roots.config, { waitMs: 30_000 }, () =>
pull({ reporter, roots }),
);
notice("info", `pull: ${result.message}`, { summary: result.summary });
if (result.changed) {
notice(
"info",
`Config updated from GitHub (${result.files.length} file(s)). Restart OpenCode to apply.`,
);
} else if (announce) {
notice("info", "Config already up to date.");
}
} catch (error) {
const message = (error as Error).message;
notice("warn", `pull failed: ${message}`);
if (announce) notice("warn", `Sync pull failed: ${message}`);
}
}

async function runPush(client: any, announce: boolean): Promise<void> {
const roots = getRoots();
const reporter = new CollectingReporter();
Expand All @@ -99,6 +160,71 @@ async function runPush(client: any, announce: boolean): Promise<void> {
}
}

async function runPushV2(announce: boolean): Promise<void> {
const roots = getRoots();
const reporter = new CollectingReporter();
try {
const result = await withSyncLock(roots.config, { waitMs: 30_000 }, () =>
push({ reporter, roots }),
);
notice("info", `push: ${result.message}`);
if (announce) notice("info", result.message);
} catch (error) {
const message = (error as Error).message;
notice("warn", `push failed: ${message}`);
if (announce) notice("warn", `Sync push failed: ${message}`);
}
}

const SYNC_TOOL_DESCRIPTION =
"Sync OpenCode configuration with the GitHub sync repository. " +
"Use action 'push' to upload this machine's configuration, 'pull' to apply the shared " +
"configuration, or 'status' to report what is out of sync.";

const SYNC_TOOL_INPUT = {
type: "object",
properties: {
action: {
type: "string",
enum: ["push", "pull", "status"],
description: "Which sync operation to run.",
},
},
additionalProperties: false,
} as const;

async function executeSyncAction(args: { action?: string }): Promise<string> {
const roots = getRoots();
const action = args?.action ?? "status";
const reporter = new CollectingReporter();

if (action === "status") {
const state = status({ roots });
return JSON.stringify(state, null, 2);
}

const result = await withSyncLock(roots.config, { waitMs: 60_000 }, () =>
action === "push" ? push({ reporter, roots }) : pull({ reporter, roots }),
);

const lines = [result.message];
if (result.files.length > 0) {
lines.push(
`Files: ${result.files
.slice(0, 20)
.map((f) => `${f.kind[0]} ${f.path}`)
.join(", ")}`,
);
}
if (result.restartRequired && result.changed) {
lines.push("Restart OpenCode for the new configuration to take effect.");
}
if (reporter.lines.length > 0) {
lines.push(...reporter.lines.filter((l) => l.level === "warn").map((l) => `! ${l.message}`));
}
return lines.join("\n");
}

export const OpencodeGithubSync = async (ctx: PluginContext) => {
const client = ctx?.client;
const roots = getRoots();
Expand Down Expand Up @@ -129,10 +255,7 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => {

tool: {
opencode_sync: {
description:
"Sync OpenCode configuration with the GitHub sync repository. " +
"Use action 'push' to upload this machine's configuration, 'pull' to apply the shared " +
"configuration, or 'status' to report what is out of sync.",
description: SYNC_TOOL_DESCRIPTION,
args: {
action: {
type: "string",
Expand All @@ -141,40 +264,67 @@ export const OpencodeGithubSync = async (ctx: PluginContext) => {
},
},
async execute(args: { action?: string }) {
const action = args?.action ?? "status";
const reporter = new CollectingReporter();

if (action === "status") {
const state = status({ roots });
return JSON.stringify(state, null, 2);
}

const result = await withSyncLock(roots.config, { waitMs: 60_000 }, () =>
action === "push" ? push({ reporter, roots }) : pull({ reporter, roots }),
);

const lines = [result.message];
if (result.files.length > 0) {
lines.push(
`Files: ${result.files
.slice(0, 20)
.map((f) => `${f.kind[0]} ${f.path}`)
.join(", ")}`,
);
}
if (result.restartRequired && result.changed) {
lines.push("Restart OpenCode for the new configuration to take effect.");
}
if (reporter.lines.length > 0) {
lines.push(
...reporter.lines.filter((l) => l.level === "warn").map((l) => `! ${l.message}`),
);
}
return lines.join("\n");
return executeSyncAction(args);
},
},
},
};
};

export default OpencodeGithubSync;
async function setup(ctx: V2Context): Promise<() => void> {
const controller = new AbortController();
const roots = getRoots();

if (!isConfigured()) {
notice(
"info",
"opencode-github-sync is installed but no repository is configured. Run `opencode-sync init`.",
);
return () => controller.abort();
}

const settings = loadSettings(roots.config);

if (settings.autoPullOnStartup) {
// Deliberately not awaited: OpenCode should finish starting even when the
// network is slow or GitHub is unreachable.
void runPullV2(false);
}

await ctx.tool.transform((editor) => {
editor.add({
name: "opencode_sync",
description: SYNC_TOOL_DESCRIPTION,
input: SYNC_TOOL_INPUT,
async execute(input) {
return { content: await executeSyncAction(input as { action?: string }) };
},
});
});

if (settings.autoPushOnIdle) {
void (async () => {
try {
for await (const event of ctx.event.subscribe({ signal: controller.signal })) {
if (event.type === "session.idle") {
void runPushV2(false);
}
}
} catch {
// Aborted on unload — nothing to report.
}
})();
}

return () => controller.abort();
}

const V2Plugin = {
id: "opencode-github-sync",
setup,
};

export default {
...V2Plugin,
server: OpencodeGithubSync,
};
Loading