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
46 changes: 22 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,25 @@ opencode

## Configure

Run `/sync-init` to get started. This will:

1. Detect your GitHub username from the CLI
2. Create a private repo (`my-opencode-config` by default) if it doesn't exist
3. Clone the repo and set up sync

That's it! Your config will now sync automatically on startup.

### Custom repo name or org

You can specify a custom repo name or use an organization:

- `/sync-init` - Uses `{your-username}/my-opencode-config`
- `/sync-init my-config` - Uses `{your-username}/my-config`
- `/sync-init my-org/team-config` - Uses `my-org/team-config`

<details>
<summary>Manual configuration</summary>

Create `~/.config/opencode/opencode-sync.jsonc`:

```jsonc
Expand All @@ -53,7 +72,7 @@ Create `~/.config/opencode/opencode-sync.jsonc`:
}
```

You can also run `/sync-init` to scaffold this file.
</details>

### Synced paths (default)

Expand Down Expand Up @@ -117,36 +136,15 @@ Overrides are merged into the runtime config and re-applied to `opencode.json(c)

## Usage

- `/sync-init` to set up sync (creates repo if needed)
- `/sync-status` for repo status and last sync
- `/sync-pull` to fetch and apply remote config
- `/sync-push` to commit and push local changes
- `/sync-enable-secrets` to opt in to secrets sync
- `/sync-resolve` to automatically resolve uncommitted changes using AI

<details>
<summary>Manual (slash command alternative)</summary>

### Configure

Create `~/.config/opencode/opencode-sync.jsonc`:

```jsonc
{
"repo": {
"owner": "your-org",
"name": "opencode-config",
"branch": "main",
},
"includeSecrets": false,
"includeSessions": false,
"includePromptStash": false,
"extraSecretPaths": [],
}
```

### Enable secrets (private repo required)

Set `"includeSecrets": true` and optionally add `"extraSecretPaths"`. The plugin will refuse to sync secrets if the repo is not private.
<summary>Manual sync (without slash commands)</summary>

### Trigger a sync

Expand Down
8 changes: 6 additions & 2 deletions src/command/sync-init.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
---
description: Initialize opencode-sync configuration
---

Use the opencode_sync tool with command "init".
If the repo is unknown, ask for a GitHub repo (owner/name or URL).
If the user wants to create it, pass create=true and private=true unless they request public.
The repo will be created automatically if it doesn't exist (private by default).
Default repo name is "my-opencode-config" with owner auto-detected from GitHub CLI.
If the user wants a custom repo name, pass name="custom-name".
If the user wants an org-owned repo, pass owner="org-name".
If the user wants a public repo, pass private=false.
Include includeSecrets if the user explicitly opts in.
20 changes: 20 additions & 0 deletions src/sync/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,3 +236,23 @@ function formatError(error: unknown): string {
if (error instanceof Error) return error.message;
return String(error);
}

export async function repoExists($: Shell, repoIdentifier: string): Promise<boolean> {
try {
await $`gh repo view ${repoIdentifier} --json name`;
return true;
} catch {
return false;
}
}

export async function getAuthenticatedUser($: Shell): Promise<string> {
try {
const output = await $`gh api user --jq .login`.text();
return output.trim();
} catch (error) {
throw new SyncCommandError(
`Failed to detect GitHub user. Ensure gh is authenticated: ${formatError(error)}`
);
}
}
49 changes: 33 additions & 16 deletions src/sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,12 @@ import {
ensureRepoCloned,
ensureRepoPrivate,
fetchAndFastForward,
getAuthenticatedUser,
getRepoStatus,
hasLocalChanges,
isRepoCloned,
pushBranch,
repoExists,
resolveRepoBranch,
resolveRepoIdentifier,
} from './repo.ts';
Expand Down Expand Up @@ -125,27 +127,31 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
return statusLines.join('\n');
},
init: async (options: InitOptions) => {
const config = buildConfigFromInit(options);
if (!config.repo) {
throw new SyncCommandError('Provide repo info (owner/name or URL) to initialize sync.');
}
const config = await buildConfigFromInit(ctx.$, options);

const repoIdentifier = resolveRepoIdentifier(config);
if (options.create) {
await createRepo(ctx.$, config, options.private ?? true);
const isPrivate = options.private ?? true;

const exists = await repoExists(ctx.$, repoIdentifier);
let created = false;
if (!exists) {
await createRepo(ctx.$, config, isPrivate);
created = true;
}

await writeSyncConfig(locations, config);
const repoRoot = resolveRepoRoot(config, locations);
await ensureRepoCloned(ctx.$, config, repoRoot);
await ensureSecretsPolicy(ctx, config);

return [
const lines = [
'opencode-sync configured.',
`Repo: ${repoIdentifier}`,
`Repo: ${repoIdentifier}${created ? ' (created)' : ''}`,
`Branch: ${resolveRepoBranch(config)}`,
`Local repo: ${repoRoot}`,
].join('\n');
];

return lines.join('\n');
},
pull: async () => {
const config = await getConfigOrThrow(locations);
Expand Down Expand Up @@ -340,8 +346,10 @@ async function resolveBranch(
}
}

function buildConfigFromInit(options: InitOptions) {
const repo = resolveRepoFromInit(options);
const DEFAULT_REPO_NAME = 'my-opencode-config';

async function buildConfigFromInit($: Shell, options: InitOptions) {
const repo = await resolveRepoFromInit($, options);
return normalizeSyncConfig({
repo,
includeSecrets: options.includeSecrets ?? false,
Expand All @@ -352,7 +360,7 @@ function buildConfigFromInit(options: InitOptions) {
});
}

function resolveRepoFromInit(options: InitOptions) {
async function resolveRepoFromInit($: Shell, options: InitOptions) {
if (options.url) {
return { url: options.url, branch: options.branch };
}
Expand All @@ -363,12 +371,21 @@ function resolveRepoFromInit(options: InitOptions) {
if (options.repo.includes('://') || options.repo.endsWith('.git')) {
return { url: options.repo, branch: options.branch };
}
const [owner, name] = options.repo.split('/');
if (owner && name) {
return { owner, name, branch: options.branch };
if (options.repo.includes('/')) {
const [owner, name] = options.repo.split('/');
if (owner && name) {
return { owner, name, branch: options.branch };
}
}

const owner = await getAuthenticatedUser($);
return { owner, name: options.repo, branch: options.branch };
}
return undefined;

// Default: auto-detect owner, use default repo name
const owner = await getAuthenticatedUser($);
const name = DEFAULT_REPO_NAME;
return { owner, name, branch: options.branch };
}

async function createRepo(
Expand Down
Loading