diff --git a/README.md b/README.md index a3c5e19..b470996 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,8 @@ Create `~/.config/opencode/opencode-synced.jsonc`: }, "includePromptStash": false, "includeModelFavorites": true, + "includeOpencodeSkills": true, + "includeAgentsDir": true, "extraSecretPaths": [], "extraConfigPaths": [], } @@ -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) diff --git a/src/command/sync-init.md b/src/command/sync-init.md index 083dde5..50ebe35 100644 --- a/src/command/sync-init.md +++ b/src/command/sync-init.md @@ -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. diff --git a/src/index.ts b/src/index.ts index dc085ee..04fd7a5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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(), @@ -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, diff --git a/src/sync/config.test.ts b/src/sync/config.test.ts index cd5cfc2..ef3f276 100644 --- a/src/sync/config.test.ts +++ b/src/sync/config.test.ts @@ -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([]); diff --git a/src/sync/config.ts b/src/sync/config.ts index 5438eda..df2f6a5 100644 --- a/src/sync/config.ts +++ b/src/sync/config.ts @@ -59,6 +59,8 @@ export interface SyncConfig { sessionBackend?: SessionBackendConfig; includePromptStash?: boolean; includeModelFavorites?: boolean; + includeOpencodeSkills?: boolean; + includeAgentsDir?: boolean; secretsBackend?: SecretsBackendConfig; extraSecretPaths?: string[]; extraConfigPaths?: string[]; @@ -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[]; @@ -169,6 +173,8 @@ 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, @@ -176,6 +182,8 @@ export function normalizeSyncConfig(config: SyncConfig): NormalizedSyncConfig { 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 : [], diff --git a/src/sync/paths.test.ts b/src/sync/paths.test.ts index 1bcf830..57b60d8 100644 --- a/src/sync/paths.test.ts +++ b/src/sync/paths.test.ts @@ -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'); diff --git a/src/sync/paths.ts b/src/sync/paths.ts index 921fcb0..54bd147 100644 --- a/src/sync/paths.ts +++ b/src/sync/paths.ts @@ -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, @@ -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), @@ -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( diff --git a/src/sync/service.ts b/src/sync/service.ts index 7b7e8b3..3604254 100644 --- a/src/sync/service.ts +++ b/src/sync/service.ts @@ -74,6 +74,8 @@ interface InitOptions { includeModelFavorites?: boolean; setupTurso?: boolean; migrateSessions?: boolean; + includeOpencodeSkills?: boolean; + includeAgentsDir?: boolean; create?: boolean; private?: boolean; extraSecretPaths?: string[]; @@ -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'; @@ -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}`, @@ -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,