From 9151c9baa997a1ba74030abd23098efdb2914233 Mon Sep 17 00:00:00 2001 From: Akshar Patel Date: Mon, 10 Aug 2026 10:55:20 -0400 Subject: [PATCH] feat: propagate new plugins across shared profiles --- README.md | 30 +++++++-- src/cli.js | 90 ++++++++++++++++++++++----- src/core/plugins.js | 78 ++++++++++++++++++++++-- test/cli.test.js | 26 +++++--- test/plugins.test.js | 142 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 334 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 9a1cad7..829de1a 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Set up SkillSync completely on this device. 3. Let setup detect my Codex, OpenCode, Claude Code, and Hermes skill folders. Show me any existing standalone skills and ask which ones I want to import. Do not import or resolve differing skill content without asking me. 4. Install the persistent CLI with `npm install -g @akshar5/skillsync@latest`, then run `skillsync service install`. 5. Ask whether I want to sync global agent instructions. If I do, inspect my existing Codex and OpenCode AGENTS.md files, import the version I choose, and link each installed provider's global path to that profile. Only manage CLAUDE.md when Claude Code is installed. Preserve any differing unmanaged file. -6. Ask whether I want to sync installed Codex plugins. If I do, inspect `codex plugin list --json`, ask which enabled user-managed plugins are portable, then pass those exact selectors to `skillsync plugins import --name shared --plugin PLUGIN@MARKETPLACE`. Assign that profile to the devices I choose. Explain that connector sign-ins are separate and must never be copied. +6. Ask whether I want to sync installed Codex plugins. If I want one universal profile, run `skillsync plugins import --name shared --auto-adopt`; every current and future enabled user-managed plugin on an assigned device will join it. If I want a selected profile instead, inspect `codex plugin list --json`, ask which plugins are portable, and pass those exact selectors with `--plugin PLUGIN@MARKETPLACE`. Assign the profile to the devices I choose. Explain that connector sign-ins are separate and must never be copied. 7. Verify `skillsync doctor`, `skillsync status`, `skillsync matrix`, `skillsync instructions status`, `skillsync plugins status`, and the background service. Report the vault, detected targets, auto-adoption settings, instruction and plugin profiles, service state, and anything that still needs my decision. ``` @@ -156,13 +156,19 @@ After a profile switch, the old profile remains available while any device still Codex installs plugins per environment. SkillSync stores the selected plugin identifiers in a named profile, assigns that profile per device, and additively installs anything missing during sync. -On the device whose plugin selection you want to copy: +For one universal profile containing every current and future eligible plugin on its assigned devices: ```bash -skillsync plugins import --name shared +skillsync plugins import --name shared --auto-adopt ``` -In an interactive terminal, select the user-managed plugins that belong in the profile. For a non-interactive import, list them explicitly: +For a selected profile instead, choose plugins interactively: + +```bash +skillsync plugins import --name selected +``` + +Or list them explicitly in a non-interactive environment: ```bash skillsync plugins import --name shared \ @@ -177,7 +183,18 @@ skillsync plugins use shared --device devbox skillsync plugins status ``` -The other device installs missing plugins on its next sync. Existing extra plugins remain installed, and SkillSync never removes plugins. A disabled desired plugin stays pending; enable it from Codex's `/plugins` interface. Profiles synchronize plugin selection, while Codex continues to manage bundle versions and upgrades. Start a new Codex session after plugins are installed. +The other device installs missing plugins on its next sync. With auto-adoption enabled, every assigned device also contributes its durable, enabled user-managed plugins to the effective profile. Installing a plugin on the VPS therefore adds it to `shared` during the VPS's next sync, and the Mac and other assigned devices install it after they sync. + +SkillSync derives this union from separate per-device reports instead of having devices rewrite one profile file. Concurrent device syncs therefore update different files. Product-managed, disabled, cached, and hosted-session-only plugins never join the union. + +Automatic adoption is off unless `--auto-adopt` is supplied. Change it later with: + +```bash +skillsync plugins auto-adopt shared on +skillsync plugins auto-adopt shared off +``` + +Existing extra plugins remain installed, and SkillSync never removes plugins. Once an automatically adopted plugin propagates to other devices, it remains part of their reported inventories; disabling auto-adoption stops future additions but does not uninstall anything already present. A disabled desired plugin stays pending; enable it from Codex's `/plugins` interface. Profiles synchronize plugin selection, while Codex continues to manage bundle versions and upgrades. Start a new Codex session after plugins are installed. Plugin installation and connector authorization are separate. For example, SkillSync can install the Gmail plugin on a VPS, but it does not copy the Mac's Google OAuth session, API keys, cookies, or other credentials. If both Codex environments use the same account or workspace, its connector authorization may already be available; otherwise Gmail requires sign-in there. Environments that cannot complete the connector's sign-in flow may have the plugin installed but still be unable to use Gmail. @@ -369,8 +386,9 @@ skillsync instructions disable [--device id] skillsync plugins status skillsync plugins profiles skillsync plugins show -skillsync plugins import --name [--plugin plugin@marketplace] +skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt] skillsync plugins use [--device id] +skillsync plugins auto-adopt skillsync device list skillsync device show skillsync groups [--summary] diff --git a/src/cli.js b/src/cli.js index 02487ec..3b50051 100755 --- a/src/cli.js +++ b/src/cli.js @@ -74,6 +74,7 @@ import { } from './core/registry.js'; import { assignPluginProfile, + effectivePluginProfile, importablePluginSelectors, inspectCodexPlugins, listPluginAssignments, @@ -82,6 +83,7 @@ import { loadPluginProfile, pluginProfileHash, savePluginProfile, + setPluginProfileAutoAdopt, } from './core/plugins.js'; import { cloneSkillSource, discoverSkillFolders, importSourceForAgent, isRemoteSkillSource, selectDiscoveredSkills, supportedImportSources } from './core/source.js'; import { bootstrapLaunchAgent, daemonInvocation, renderLaunchAgent, renderSystemdUserService } from './core/service.js'; @@ -670,13 +672,21 @@ async function pluginsCommand(rest = []) { const config = await configured(); if (subcommand === 'profiles') { - const profiles = await listPluginProfiles(config.repoPath); + const [storedProfiles, assignments, states] = await Promise.all([ + listPluginProfiles(config.repoPath), + listPluginAssignments(config.repoPath), + listPluginStates(config.repoPath), + ]); + const profiles = storedProfiles.map((profile) => effectivePluginProfile(profile, { + assignments, + states, + })); if (!profiles.length) { console.log('No Codex plugin profiles are configured.'); return; } for (const profile of profiles) { - console.log(`${profile.name}\t${profile.plugins.length} plugins`); + console.log(`${profile.name}\t${profile.plugins.length} plugins\tauto-adopt ${profile.auto_adopt ? 'on' : 'off'}`); } return; } @@ -684,8 +694,13 @@ async function pluginsCommand(rest = []) { if (subcommand === 'show') { const name = rest[1]; if (!name) throw new Error('Usage: skillsync plugins show '); - const profile = await loadPluginProfile(config.repoPath, name); - console.log(`${profile.name} (${profile.plugins.length} plugins)`); + const [storedProfile, assignments, states] = await Promise.all([ + loadPluginProfile(config.repoPath, name), + listPluginAssignments(config.repoPath), + listPluginStates(config.repoPath), + ]); + const profile = effectivePluginProfile(storedProfile, { assignments, states }); + console.log(`${profile.name} (${profile.plugins.length} plugins; auto-adopt ${profile.auto_adopt ? 'on' : 'off'})`); for (const selector of profile.plugins) console.log(`- ${selector}`); return; } @@ -693,7 +708,10 @@ async function pluginsCommand(rest = []) { if (subcommand === 'import') { const name = flagValue(rest, '--name') || rest[1]; if (!name || name.startsWith('-')) { - throw new Error('Usage: skillsync plugins import --name [--plugin plugin@marketplace]'); + throw new Error('Usage: skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt]'); + } + if (hasFlag(rest, '--auto-adopt') && hasFlag(rest, '--no-auto-adopt')) { + throw new Error('Choose either --auto-adopt or --no-auto-adopt'); } const inspection = await inspectCodexPlugins(); if (!inspection.available) throw new Error(inspection.error); @@ -702,15 +720,17 @@ async function pluginsCommand(rest = []) { throw new Error('No enabled, user-managed Codex plugins are installed on this device'); } const requested = flagList(rest, ['--plugin', '--plugins']); - if (!requested.length && !process.stdin.isTTY) { + if (!requested.length && !hasFlag(rest, '--auto-adopt') && !process.stdin.isTTY) { throw new Error('Non-interactive plugin import requires --plugin plugin@marketplace'); } - const selected = requested.length - ? requested - : await promptWithEscape(checkbox({ + let selected = requested; + if (!selected.length && hasFlag(rest, '--auto-adopt')) selected = importable; + if (!selected.length) { + selected = await promptWithEscape(checkbox({ message: 'Which Codex plugins should this profile manage?', choices: importable.map((selector) => ({ name: selector, value: selector })), }), []); + } if (!selected.length) return; const unknown = selected.filter((selector) => !importable.includes(selector)); if (unknown.length) { @@ -721,10 +741,18 @@ async function pluginsCommand(rest = []) { deviceId: config.deviceId, pull: true, }); + const existing = await loadPluginProfile(config.repoPath, name).catch((error) => { + if (error.message === `Plugin profile not found: ${name}`) return null; + throw error; + }); + let autoAdopt = existing?.auto_adopt ?? false; + if (hasFlag(rest, '--auto-adopt')) autoAdopt = true; + if (hasFlag(rest, '--no-auto-adopt')) autoAdopt = false; const profile = await savePluginProfile({ vaultPath: config.repoPath, name, plugins: selected, + autoAdopt, }); await assignPluginProfile({ vaultPath: config.repoPath, @@ -736,7 +764,7 @@ async function pluginsCommand(rest = []) { deviceId: config.deviceId, pull: false, }); - console.log(`Imported ${profile.plugins.length} Codex plugins into ${profile.name} and assigned it to ${config.deviceId}.`); + console.log(`Imported ${profile.plugins.length} Codex plugins into ${profile.name}, assigned it to ${config.deviceId}, and set auto-adopt ${profile.auto_adopt ? 'on' : 'off'}.`); return; } @@ -760,8 +788,34 @@ async function pluginsCommand(rest = []) { return; } + if (subcommand === 'auto-adopt') { + const name = rest[1]; + const value = rest[2]; + if (!name || !value) { + throw new Error('Usage: skillsync plugins auto-adopt '); + } + const enabled = parseOnOff(value); + await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: true, + }); + await setPluginProfileAutoAdopt({ + vaultPath: config.repoPath, + name, + enabled, + }); + await syncVault({ + vaultPath: config.repoPath, + deviceId: config.deviceId, + pull: false, + }); + console.log(`Plugin auto-adoption for ${name}: ${enabled ? 'on' : 'off'}`); + return; + } + if (subcommand !== 'status') { - throw new Error('Usage: skillsync plugins status|profiles|show |import --name |use [--device id]'); + throw new Error('Usage: skillsync plugins status|profiles|show |import --name |use [--device id]|auto-adopt '); } const [profiles, assignments, states, devices, localInspection] = await Promise.all([ @@ -771,12 +825,20 @@ async function pluginsCommand(rest = []) { listDevices(config.repoPath), inspectCodexPlugins(), ]); - const profileByName = new Map(profiles.map((profile) => [profile.name, profile])); + const effectiveProfiles = profiles.map((profile) => effectivePluginProfile(profile, { + assignments, + states, + deviceId: config.deviceId, + installed: localInspection.available ? localInspection.installed : null, + })); + const profileByName = new Map(effectiveProfiles.map((profile) => [profile.name, profile])); const assignmentByDevice = new Map(assignments.map((assignment) => [assignment.device_id, assignment])); const stateByDevice = new Map(states.map((state) => [state.device_id, state])); console.log('Codex plugin profiles:'); if (!profiles.length) console.log('- none'); - for (const profile of profiles) console.log(`- ${profile.name}: ${profile.plugins.length} plugins`); + for (const profile of effectiveProfiles) { + console.log(`- ${profile.name}: ${profile.plugins.length} plugins (auto-adopt ${profile.auto_adopt ? 'on' : 'off'})`); + } console.log('Devices:'); for (const device of devices) { const assignment = assignmentByDevice.get(device.device_id); @@ -2742,5 +2804,5 @@ async function instructionProfileSettingsScreen(config, device) { } function help() { - console.log(`SkillSync\n\nUsage:\n skillsync Open TUI\n skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes]\n skillsync connect [--path path]\n skillsync status\n skillsync list\n skillsync installed [--device id]\n skillsync matrix [--edit]\n skillsync instructions status\n skillsync instructions profiles\n skillsync instructions import [--name profile] [--from path] [--to path] [--separate]\n skillsync instructions use [--device id] [--path path]\n skillsync instructions use-device [--device target-device]\n skillsync instructions fork [profile]\n skillsync instructions link \n skillsync instructions unlink \n skillsync instructions enable [--profile profile] [--path path] [--from-local|--use-vault]\n skillsync instructions disable [--device id]\n skillsync plugins status\n skillsync plugins profiles\n skillsync plugins show \n skillsync plugins import --name [--plugin plugin@marketplace]\n skillsync plugins use [--device id]\n skillsync device list\n skillsync device show \n skillsync groups [--summary]\n skillsync pack list\n skillsync pack show \n skillsync pack install [--target targets] [--global]\n skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync import [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync install [--device id] [--target targets] [--global]\n skillsync uninstall [--device id] [--target targets] [--global]\n skillsync delete [--yes]\n skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt]\n skillsync target remove \n skillsync target auto-adopt \n skillsync auto-adopt [show|on|off]\n skillsync policy show\n skillsync policy set delete-unassigned-skills \n skillsync scan\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); + console.log(`SkillSync\n\nUsage:\n skillsync Open TUI\n skillsync setup [--name skills] [--repo owner/repo|url] [--path path] [--yes]\n skillsync connect [--path path]\n skillsync status\n skillsync list\n skillsync installed [--device id]\n skillsync matrix [--edit]\n skillsync instructions status\n skillsync instructions profiles\n skillsync instructions import [--name profile] [--from path] [--to path] [--separate]\n skillsync instructions use [--device id] [--path path]\n skillsync instructions use-device [--device target-device]\n skillsync instructions fork [profile]\n skillsync instructions link \n skillsync instructions unlink \n skillsync instructions enable [--profile profile] [--path path] [--from-local|--use-vault]\n skillsync instructions disable [--device id]\n skillsync plugins status\n skillsync plugins profiles\n skillsync plugins show \n skillsync plugins import --name [--plugin plugin@marketplace] [--auto-adopt|--no-auto-adopt]\n skillsync plugins use [--device id]\n skillsync plugins auto-adopt \n skillsync device list\n skillsync device show \n skillsync groups [--summary]\n skillsync pack list\n skillsync pack show \n skillsync pack install [--target targets] [--global]\n skillsync add [--name name] [--skill name] [--target targets] [--global] [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync import [--conflict skip|use-vault|overwrite-vault|rename]\n skillsync install [--device id] [--target targets] [--global]\n skillsync uninstall [--device id] [--target targets] [--global]\n skillsync delete [--yes]\n skillsync target add [--mode symlink|copy] [--scan-path path] [--no-auto-adopt]\n skillsync target remove \n skillsync target auto-adopt \n skillsync auto-adopt [show|on|off]\n skillsync policy show\n skillsync policy set delete-unassigned-skills \n skillsync scan\n skillsync sync\n skillsync service install\n skillsync doctor\n skillsync daemon\n`); } diff --git a/src/core/plugins.js b/src/core/plugins.js index 18fbaf3..b1dae51 100644 --- a/src/core/plugins.js +++ b/src/core/plugins.js @@ -63,6 +63,7 @@ function normalizeProfile(value, profile) { version: 1, name, provider: 'codex', + auto_adopt: value.auto_adopt === true, plugins: [...new Set(selectors.map(normalizePluginSelector))].sort(), }; } @@ -71,12 +72,22 @@ export function pluginProfileHash(profile) { return `sha256:${createHash('sha256').update(JSON.stringify(profile)).digest('hex')}`; } -export async function savePluginProfile({ vaultPath, name, plugins }) { - const profile = normalizeProfile({ name, plugins }, name); +export async function savePluginProfile({ vaultPath, name, plugins, autoAdopt = false }) { + const profile = normalizeProfile({ name, plugins, auto_adopt: autoAdopt }, name); await writeJson(pluginProfilePath(vaultPath, profile.name), profile); return profile; } +export async function setPluginProfileAutoAdopt({ vaultPath, name, enabled }) { + const profile = await loadPluginProfile(vaultPath, name); + return savePluginProfile({ + vaultPath, + name: profile.name, + plugins: profile.plugins, + autoAdopt: enabled, + }); +} + export async function loadPluginProfile(vaultPath, name) { assertSafePathSegment(name, 'Plugin profile'); const profile = await readJson(pluginProfilePath(vaultPath, name), null); @@ -175,6 +186,31 @@ export function importablePluginSelectors(plugins) { .sort(); } +export function effectivePluginProfile(profile, { + assignments = [], + states = [], + deviceId = null, + installed = null, +} = {}) { + const normalized = normalizeProfile(profile, profile.name); + if (!normalized.auto_adopt) return normalized; + const stateByDevice = new Map(states.map((state) => [state.device_id, state])); + const plugins = new Set(normalized.plugins); + for (const assignment of assignments) { + if (assignment.profile !== normalized.name) continue; + const inventory = assignment.device_id === deviceId && Array.isArray(installed) + ? installed + : stateByDevice.get(assignment.device_id)?.installed || []; + for (const selector of importablePluginSelectors(normalizePluginList({ installed: inventory }))) { + plugins.add(selector); + } + } + return { + ...normalized, + plugins: [...plugins].sort(), + }; +} + export async function inspectCodexPlugins({ commandAvailable = commandExists, runCommand = run, @@ -255,6 +291,20 @@ export async function syncCodexPlugins({ } let inspection = await inspectCodexPlugins({ commandAvailable, runCommand }); + let previousState = null; + if (profile) { + const [assignments, states] = await Promise.all([ + listPluginAssignments(vaultPath), + listPluginStates(vaultPath), + ]); + previousState = states.find((state) => state.device_id === deviceId) || null; + profile = effectivePluginProfile(profile, { + assignments, + states, + deviceId, + installed: inspection.available ? inspection.installed : null, + }); + } if (profile && inspection.available) { const installed = new Set(inspection.installed.map((plugin) => plugin.selector)); let attemptedInstall = false; @@ -272,13 +322,30 @@ export async function syncCodexPlugins({ } } - const state = pluginState({ deviceId, assignment, profile, inspection, errors }); + const reportedInspection = !inspection.available && previousState?.installed + ? { ...inspection, installed: previousState.installed } + : inspection; + const state = pluginState({ + deviceId, + assignment, + profile, + inspection: reportedInspection, + errors, + }); await writeJson(pluginStatePath(vaultPath, deviceId), state); return state; } export async function loadPluginState(vaultPath, deviceId) { - return readJson(pluginStatePath(vaultPath, deviceId), null); + const state = await readJson(pluginStatePath(vaultPath, deviceId), null); + if (!state) return null; + const expectedDeviceId = assertSafePathSegment(deviceId, 'Device ID'); + if (state.device_id !== expectedDeviceId) { + throw new Error( + `Plugin state device does not match its file: ${state.device_id || 'unknown'} != ${expectedDeviceId}`, + ); + } + return state; } export async function listPluginStates(vaultPath) { @@ -287,7 +354,8 @@ export async function listPluginStates(vaultPath) { const files = await readdir(directory); const states = []; for (const file of files.filter((name) => name.endsWith('.json')).sort()) { - const state = await readJson(path.join(directory, file), null); + const deviceId = file.slice(0, -'.json'.length); + const state = await loadPluginState(vaultPath, deviceId); if (state) states.push(state); } return states; diff --git a/test/cli.test.js b/test/cli.test.js index 88f5c15..7d0165a 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -291,18 +291,17 @@ exit 1 'import', '--name', 'shared', - '--plugin', - 'gmail@openai-curated', + '--auto-adopt', ], { cwd: path.resolve('.'), env, }); - assert.match(imported.stdout, /Imported 1 Codex plugins into shared and assigned it to macbook/); - assert.deepEqual( - JSON.parse(await readFile(path.join(vault, 'plugins', 'profiles', 'shared.json'), 'utf8')).plugins, - ['gmail@openai-curated'], - ); + assert.match(imported.stdout, /Imported 1 Codex plugins into shared, assigned it to macbook, and set auto-adopt on/); + const profilePath = path.join(vault, 'plugins', 'profiles', 'shared.json'); + const profile = JSON.parse(await readFile(profilePath, 'utf8')); + assert.deepEqual(profile.plugins, ['gmail@openai-curated']); + assert.equal(profile.auto_adopt, true); const status = await execFileAsync(process.execPath, [ path.resolve('src/cli.js'), 'plugins', @@ -313,6 +312,19 @@ exit 1 }); assert.match(status.stdout, /macbook: shared \(applied; 2 installed\)/); assert.match(status.stdout, /authentication may be required: gmail@openai-curated \(ON_INSTALL\)/); + + const disabled = await execFileAsync(process.execPath, [ + path.resolve('src/cli.js'), + 'plugins', + 'auto-adopt', + 'shared', + 'off', + ], { + cwd: path.resolve('.'), + env, + }); + assert.match(disabled.stdout, /Plugin auto-adoption for shared: off/); + assert.equal(JSON.parse(await readFile(profilePath, 'utf8')).auto_adopt, false); }); test('instructions enable adopts the global AGENTS.md and disable leaves a local copy', async () => { diff --git a/test/plugins.test.js b/test/plugins.test.js index 2d4f6c7..991a034 100644 --- a/test/plugins.test.js +++ b/test/plugins.test.js @@ -6,6 +6,7 @@ import path from 'node:path'; import { assignPluginProfile, + effectivePluginProfile, importablePluginSelectors, loadPluginAssignment, loadPluginProfile, @@ -14,6 +15,7 @@ import { normalizePluginSelector, pluginProfileHash, savePluginProfile, + setPluginProfileAutoAdopt, syncCodexPlugins, } from '../src/core/plugins.js'; @@ -74,6 +76,7 @@ test('plugin profiles are normalized and assigned per device', async () => { 'github@openai-curated', 'gmail@openai-curated', ]); + assert.equal((await loadPluginProfile(vaultPath, 'shared')).auto_adopt, false); assert.deepEqual(await loadPluginAssignment(vaultPath, 'arch'), { version: 1, device_id: 'arch', @@ -81,6 +84,53 @@ test('plugin profiles are normalized and assigned per device', async () => { }); assert.equal(pluginProfileHash(saved), pluginProfileHash(await loadPluginProfile(vaultPath, 'shared'))); assert.throws(() => normalizePluginSelector('gmail'), /plugin@marketplace/); + + await setPluginProfileAutoAdopt({ vaultPath, name: 'shared', enabled: true }); + assert.equal((await loadPluginProfile(vaultPath, 'shared')).auto_adopt, true); +}); + +test('auto-adopting profiles use eligible plugins from assigned device inventories', () => { + const profile = effectivePluginProfile({ + version: 1, + name: 'shared', + provider: 'codex', + auto_adopt: true, + plugins: ['github@openai-curated'], + }, { + assignments: [ + { device_id: 'mac', profile: 'shared' }, + { device_id: 'vps', profile: 'shared' }, + { device_id: 'personal', profile: 'other' }, + ], + states: [ + { + device_id: 'mac', + installed: normalizePluginList({ + installed: [ + { pluginId: 'notion@openai-curated', enabled: true }, + { pluginId: 'gmail@openai-curated', enabled: false }, + { pluginId: 'sites@openai-bundled', enabled: true }, + ], + }), + }, + { + device_id: 'personal', + installed: normalizePluginList({ + installed: [{ pluginId: 'private@custom-marketplace', enabled: true }], + }), + }, + ], + deviceId: 'vps', + installed: normalizePluginList({ + installed: [{ pluginId: 'vercel@openai-curated', enabled: true }], + }), + }); + + assert.deepEqual(profile.plugins, [ + 'github@openai-curated', + 'notion@openai-curated', + 'vercel@openai-curated', + ]); }); test('plugin sync installs only missing selections and preserves extra plugins', async () => { @@ -149,6 +199,59 @@ test('plugin sync installs only missing selections and preserves extra plugins', assert.deepEqual(await loadPluginState(vaultPath, deviceId), state); }); +test('auto-adopted plugins propagate from any assigned device without rewriting the profile', async () => { + const vaultPath = await tempDir(); + await savePluginProfile({ + vaultPath, + name: 'shared', + plugins: ['github@openai-curated'], + autoAdopt: true, + }); + await assignPluginProfile({ vaultPath, deviceId: 'mac', profile: 'shared' }); + await assignPluginProfile({ vaultPath, deviceId: 'vps', profile: 'shared' }); + + const runner = (initial) => { + const installed = initial.map((pluginId) => ({ pluginId, enabled: true })); + const added = []; + return { + added, + runCommand: async (_command, args) => { + if (args[1] === 'list') { + return { stdout: JSON.stringify({ installed }), stderr: '', code: 0 }; + } + added.push(args[2]); + installed.push({ pluginId: args[2], enabled: true }); + return { stdout: '{}', stderr: '', code: 0 }; + }, + }; + }; + const vps = runner([ + 'github@openai-curated', + 'notion@openai-curated', + 'sites@openai-bundled', + ]); + const vpsState = await syncCodexPlugins({ + vaultPath, + deviceId: 'vps', + commandAvailable: async () => true, + runCommand: vps.runCommand, + }); + const mac = runner(['github@openai-curated']); + const macState = await syncCodexPlugins({ + vaultPath, + deviceId: 'mac', + commandAvailable: async () => true, + runCommand: mac.runCommand, + }); + + assert.deepEqual(vps.added, []); + assert.deepEqual(mac.added, ['notion@openai-curated']); + assert.equal(vpsState.profile_hash, macState.profile_hash); + assert.deepEqual((await loadPluginProfile(vaultPath, 'shared')).plugins, [ + 'github@openai-curated', + ]); +}); + test('plugin sync reports disabled selections without deleting or reinstalling them', async () => { const vaultPath = await tempDir(); const deviceId = 'mac'; @@ -211,6 +314,45 @@ test('plugin sync never writes raw authentication failures to the vault', async assert.equal(JSON.stringify(await loadPluginState(vaultPath, deviceId)).includes('SECRET-123'), false); }); +test('failed inspection retains the last safe inventory for automatic adoption', async () => { + const vaultPath = await tempDir(); + const deviceId = 'vps'; + await savePluginProfile({ + vaultPath, + name: 'shared', + plugins: ['github@openai-curated'], + autoAdopt: true, + }); + await assignPluginProfile({ vaultPath, deviceId, profile: 'shared' }); + await syncCodexPlugins({ + vaultPath, + deviceId, + commandAvailable: async () => true, + runCommand: async () => ({ + stdout: JSON.stringify({ + installed: [ + { pluginId: 'github@openai-curated', enabled: true }, + { pluginId: 'notion@openai-curated', enabled: true }, + ], + }), + stderr: '', + code: 0, + }), + }); + + const state = await syncCodexPlugins({ + vaultPath, + deviceId, + commandAvailable: async () => false, + }); + + assert.equal(state.available, false); + assert.deepEqual(state.installed.map(({ selector }) => selector), [ + 'github@openai-curated', + 'notion@openai-curated', + ]); +}); + test('devices without a plugin assignment remain unmanaged', async () => { const vaultPath = await tempDir(); let inspected = false;