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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,8 @@ Create `~/.config/opencode/opencode-synced.jsonc`:
},
"includePromptStash": false,
"includeModelFavorites": true,
"includeOpencodeSkills": true,
"includeAgentsDir": true,
"extraSecretPaths": [],
"extraConfigPaths": [],
}
Expand All @@ -95,9 +97,14 @@ Create `~/.config/opencode/opencode-synced.jsonc`:

- `~/.config/opencode/opencode.json` and `opencode.jsonc`
- `~/.config/opencode/AGENTS.md`
- `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`
- `~/.config/opencode/agent/`, `command/`, `mode/`, `tool/`, `themes/`, `plugin/`, `skills/`
- `~/.agents/`
- `~/.local/state/opencode/model.json` (model favorites)
- Any extra paths in `extraConfigPaths` (allowlist, files or folders)
- Any additional paths in `extraConfigPaths` (allowlist, files or folders). You do not need to include default paths like `~/.config/opencode/skills` or `~/.agents`.

Disable default directory sync by setting:
- `"includeOpencodeSkills": false` to skip `~/.config/opencode/skills/`
- `"includeAgentsDir": false` to skip `~/.agents/`

### Secrets (private repos only)

Expand Down
2 changes: 2 additions & 0 deletions src/command/sync-init.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,6 @@ Rules:
- Keep repo private unless the user explicitly asked for public.
- Include `includeSecrets` only if explicitly requested.
- Include `includeMcpSecrets` only if explicitly requested and secrets are enabled.
- Include `includeOpencodeSkills` only if explicitly requested.
- Include `includeAgentsDir` only if explicitly requested.
- Include `extraConfigPaths` only if explicitly provided.
7 changes: 7 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
.boolean()
.optional()
.describe('Sync model favorites (state/model.json)'),
includeOpencodeSkills: tool.schema
.boolean()
.optional()
.describe('Sync ~/.config/opencode/skills directory'),
includeAgentsDir: tool.schema.boolean().optional().describe('Sync ~/.agents directory'),
create: tool.schema.boolean().optional().describe('Create repo if missing'),
private: tool.schema.boolean().optional().describe('Create repo as private'),
extraSecretPaths: tool.schema.array(tool.schema.string()).optional(),
Expand Down Expand Up @@ -194,6 +199,8 @@ export const opencodeConfigSync: Plugin = async (ctx) => {
includeModelFavorites: args.includeModelFavorites,
setupTurso: args.setupTurso,
migrateSessions: args.migrateSessions,
includeOpencodeSkills: args.includeOpencodeSkills,
includeAgentsDir: args.includeAgentsDir,
create: args.create,
private: args.private,
extraSecretPaths: args.extraSecretPaths,
Expand Down
15 changes: 15 additions & 0 deletions src/sync/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@ describe('normalizeSyncConfig', () => {
expect(normalized.includeModelFavorites).toBe(true);
});

it('enables skills and home .agents by default', () => {
const normalized = normalizeSyncConfig({});
expect(normalized.includeOpencodeSkills).toBe(true);
expect(normalized.includeAgentsDir).toBe(true);
});

it('allows disabling skills and home .agents', () => {
const normalized = normalizeSyncConfig({
includeOpencodeSkills: false,
includeAgentsDir: false,
});
expect(normalized.includeOpencodeSkills).toBe(false);
expect(normalized.includeAgentsDir).toBe(false);
});

it('defaults extra path lists when omitted', () => {
const normalized = normalizeSyncConfig({ includeSecrets: true });
expect(normalized.extraSecretPaths).toEqual([]);
Expand Down
8 changes: 8 additions & 0 deletions src/sync/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,8 @@ export interface SyncConfig {
sessionBackend?: SessionBackendConfig;
includePromptStash?: boolean;
includeModelFavorites?: boolean;
includeOpencodeSkills?: boolean;
includeAgentsDir?: boolean;
secretsBackend?: SecretsBackendConfig;
extraSecretPaths?: string[];
extraConfigPaths?: string[];
Expand All @@ -71,6 +73,8 @@ export interface NormalizedSyncConfig extends SyncConfig {
sessionBackend: NormalizedSessionBackendConfig;
includePromptStash: boolean;
includeModelFavorites: boolean;
includeOpencodeSkills: boolean;
includeAgentsDir: boolean;
secretsBackend?: SecretsBackendConfig;
extraSecretPaths: string[];
extraConfigPaths: string[];
Expand Down Expand Up @@ -169,13 +173,17 @@ export function isTursoSessionBackend(config: SyncConfig | NormalizedSyncConfig)
export function normalizeSyncConfig(config: SyncConfig): NormalizedSyncConfig {
const includeSecrets = Boolean(config.includeSecrets);
const includeModelFavorites = config.includeModelFavorites !== false;
const includeOpencodeSkills = config.includeOpencodeSkills !== false;
const includeAgentsDir = config.includeAgentsDir !== false;
return {
includeSecrets,
includeMcpSecrets: includeSecrets ? Boolean(config.includeMcpSecrets) : false,
includeSessions: Boolean(config.includeSessions),
sessionBackend: normalizeSessionBackend(config.sessionBackend),
includePromptStash: Boolean(config.includePromptStash),
includeModelFavorites,
includeOpencodeSkills,
includeAgentsDir,
secretsBackend: normalizeSecretsBackend(config.secretsBackend),
extraSecretPaths: Array.isArray(config.extraSecretPaths) ? config.extraSecretPaths : [],
extraConfigPaths: Array.isArray(config.extraConfigPaths) ? config.extraConfigPaths : [],
Expand Down
130 changes: 130 additions & 0 deletions src/sync/paths.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,136 @@ describe('buildSyncPlan', () => {
expect(plan.extraConfigs.allowlist.length).toBe(0);
});

it('filters default sync items from extra config paths', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const customConfigPath = `${locations.configRoot}/custom.json`;
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: [
`${locations.configRoot}/agent`,
`${locations.configRoot}/opencode.json`,
customConfigPath,
],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.allowlist).toEqual([customConfigPath]);
});

it('includes skills directory in default sync items', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
const skillsItem = plan.items.find((item) =>
item.localPath.endsWith('/.config/opencode/skills')
);

expect(skillsItem).toBeTruthy();
expect(skillsItem?.type).toBe('dir');

const disabledPlan = buildSyncPlan(
normalizeSyncConfig({ ...config, includeOpencodeSkills: false }),
locations,
'/repo',
'linux'
);
const disabledSkillsItem = disabledPlan.items.find((item) =>
item.localPath.endsWith('/.config/opencode/skills')
);
expect(disabledSkillsItem).toBeUndefined();
});

it('filters skills path from extra config paths', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: [`${locations.configRoot}/skills`],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.allowlist.length).toBe(0);
});

it('keeps non-default extra config paths when skills is also listed', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const customConfigPath = `${locations.configRoot}/custom.json`;
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: [`${locations.configRoot}/skills`, customConfigPath],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');

expect(plan.extraConfigs.allowlist).toEqual([customConfigPath]);
});

it('includes home .agents directory by default and allows disabling', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
const agentsItem = plan.items.find((item) => item.localPath.endsWith('/.agents'));

expect(agentsItem).toBeTruthy();
expect(agentsItem?.repoPath.endsWith('/config/.agents')).toBe(true);
expect(agentsItem?.type).toBe('dir');

const disabledPlan = buildSyncPlan(
normalizeSyncConfig({ ...config, includeAgentsDir: false }),
locations,
'/repo',
'linux'
);
const disabledAgentsItem = disabledPlan.items.find((item) =>
item.localPath.endsWith('/.agents')
);
expect(disabledAgentsItem).toBeUndefined();
});

it('filters home .agents path from extra config paths', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['~/.agents'],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
expect(plan.extraConfigs.allowlist.length).toBe(0);
});

it('keeps non-default extra config paths when home .agents is also listed', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
const customConfigPath = `${locations.configRoot}/custom.json`;
const config: SyncConfig = {
repo: { owner: 'acme', name: 'config' },
includeSecrets: false,
extraConfigPaths: ['~/.agents', customConfigPath],
};

const plan = buildSyncPlan(normalizeSyncConfig(config), locations, '/repo', 'linux');
expect(plan.extraConfigs.allowlist).toEqual([customConfigPath]);
});

it('includes secrets when includeSecrets is true', () => {
const env = { HOME: '/home/test' } as NodeJS.ProcessEnv;
const locations = resolveSyncLocations(env, 'linux');
Expand Down
25 changes: 24 additions & 1 deletion src/sync/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ const SESSION_DIRS = ['storage/session', 'storage/message', 'storage/part', 'sto
const SESSION_DB_FILE = 'opencode.db';
const PROMPT_STASH_FILES = ['prompt-stash.jsonl', 'prompt-history.jsonl'];
const MODEL_FAVORITES_FILE = 'model.json';
const SKILLS_DIR = 'skills';
const HOME_AGENTS_DIR = '.agents';

export function resolveHomeDir(
env: NodeJS.ProcessEnv = process.env,
Expand Down Expand Up @@ -216,6 +218,26 @@ export function buildSyncPlan(
});
}

if (config.includeOpencodeSkills !== false) {
items.push({
localPath: path.join(configRoot, SKILLS_DIR),
repoPath: path.join(repoConfigRoot, SKILLS_DIR),
type: 'dir',
isSecret: false,
isConfigFile: false,
});
}

if (config.includeAgentsDir !== false) {
items.push({
localPath: path.join(locations.xdg.homeDir, HOME_AGENTS_DIR),
repoPath: path.join(repoConfigRoot, HOME_AGENTS_DIR),
type: 'dir',
isSecret: false,
isConfigFile: false,
});
}

if (config.includeModelFavorites !== false) {
items.push({
localPath: path.join(stateRoot, MODEL_FAVORITES_FILE),
Expand Down Expand Up @@ -299,7 +321,8 @@ export function buildSyncPlan(
);

const extraConfigPaths = (config.extraConfigPaths ?? []).filter(
(entry) => !isSamePath(entry, locations.syncConfigPath, locations.xdg.homeDir, platform)
(entry) =>
!items.some((item) => isSamePath(entry, item.localPath, locations.xdg.homeDir, platform))
);

const extraConfigs = buildExtraPathPlan(
Expand Down
8 changes: 8 additions & 0 deletions src/sync/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ interface InitOptions {
includeModelFavorites?: boolean;
setupTurso?: boolean;
migrateSessions?: boolean;
includeOpencodeSkills?: boolean;
includeAgentsDir?: boolean;
create?: boolean;
private?: boolean;
extraSecretPaths?: string[];
Expand Down Expand Up @@ -677,6 +679,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
: 'git (best effort, may conflict with concurrent writers)';
const includePromptStash = config.includePromptStash ? 'enabled' : 'disabled';
const includeModelFavorites = config.includeModelFavorites ? 'enabled' : 'disabled';
const includeOpencodeSkills = config.includeOpencodeSkills ? 'enabled' : 'disabled';
const includeAgentsDir = config.includeAgentsDir ? 'enabled' : 'disabled';
const secretsBackend = config.secretsBackend?.type ?? 'none';
const lastPull = state.lastPull ?? 'never';
const lastPush = state.lastPush ?? 'never';
Expand Down Expand Up @@ -714,6 +718,8 @@ export function createSyncService(ctx: SyncServiceContext): SyncService {
`Last session push: ${lastSessionPush}`,
`Prompt stash: ${includePromptStash}`,
`Model favorites: ${includeModelFavorites}`,
`Skills: ${includeOpencodeSkills}`,
`Home .agents: ${includeAgentsDir}`,
`Last pull: ${lastPull}`,
`Last push: ${lastPush}`,
`Working tree: ${changesLabel}`,
Expand Down Expand Up @@ -1446,6 +1452,8 @@ async function buildConfigFromInit($: Shell, options: InitOptions) {
: undefined,
includePromptStash: options.includePromptStash ?? false,
includeModelFavorites: options.includeModelFavorites ?? true,
includeOpencodeSkills: options.includeOpencodeSkills ?? true,
includeAgentsDir: options.includeAgentsDir ?? true,
extraSecretPaths: options.extraSecretPaths ?? [],
extraConfigPaths: options.extraConfigPaths ?? [],
localRepoPath: options.localRepoPath,
Expand Down
Loading