From 315e85ead5a199e7f4a4127543bc673c881c850a Mon Sep 17 00:00:00 2001 From: zhangxaochen Date: Sun, 8 Mar 2026 01:30:02 +0800 Subject: [PATCH] feat(cli): add builtin /skills sync command --- .../cli/src/ui/commands/skillsCommand.test.ts | 81 +++++++- packages/cli/src/ui/commands/skillsCommand.ts | 65 +++++++ packages/cli/src/utils/skillUtils.test.ts | 110 ++++++++++- packages/cli/src/utils/skillUtils.ts | 176 ++++++++++++++++++ 4 files changed, 430 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 89f690e1436..ae9b68d5107 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -23,6 +23,7 @@ vi.mock('../../utils/skillUtils.js', async (importOriginal) => { return { ...actual, linkSkill: vi.fn(), + syncSkills: vi.fn(), }; }); @@ -36,7 +37,7 @@ vi.mock('../../config/extensions/consent.js', async (importOriginal) => { }; }); -import { linkSkill } from '../../utils/skillUtils.js'; +import { linkSkill, syncSkills } from '../../utils/skillUtils.js'; vi.mock('../../config/settings.js', async (importOriginal) => { const actual = @@ -80,6 +81,7 @@ describe('skillsCommand', () => { ), }), getContentGenerator: vi.fn(), + reloadSkills: vi.fn().mockResolvedValue(undefined), } as unknown as Config, settings: { merged: createTestMergedSettings({ skills: { disabled: [] } }), @@ -707,4 +709,81 @@ describe('skillsCommand', () => { expect(completions).toEqual(['skill2']); }); }); + + describe('sync', () => { + it('should sync skills successfully and show details', async () => { + const syncCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'sync', + )!; + vi.mocked(syncSkills).mockResolvedValue({ + synced: ['skill1', 'skill2'], + cleaned: ['old-link'], + conflicts: ['existing'], + }); + + await syncCmd.action!(context, ''); + + expect(syncSkills).toHaveBeenCalled(); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: expect.stringContaining('Sync complete.'), + }), + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Synced 2 skills (skill1, skill2)'), + }), + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining('Cleaned up 1 stale link'), + }), + ); + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + text: expect.stringContaining( + 'Skipped 1 conflicting skill (existing)', + ), + }), + ); + expect(context.services.config!.reloadSkills).toHaveBeenCalled(); + }); + + it('should show info message if already in sync', async () => { + const syncCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'sync', + )!; + vi.mocked(syncSkills).mockResolvedValue({ + synced: [], + cleaned: [], + conflicts: [], + }); + + await syncCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.INFO, + text: 'Skills are already in sync. No changes needed.', + }), + ); + }); + + it('should show error if sync fails', async () => { + const syncCmd = skillsCommand.subCommands!.find( + (s) => s.name === 'sync', + )!; + vi.mocked(syncSkills).mockRejectedValue(new Error('Sync failed')); + + await syncCmd.action!(context, ''); + + expect(context.ui.addItem).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageType.ERROR, + text: 'Failed to sync skills: Sync failed', + }), + ); + }); + }); }); diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 714f206f369..9ce9897feac 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -22,6 +22,7 @@ import { getAdminErrorMessage } from '@google/gemini-cli-core'; import { linkSkill, renderSkillActionFeedback, + syncSkills, } from '../../utils/skillUtils.js'; import { SettingScope } from '../../config/settings.js'; import { @@ -330,6 +331,63 @@ async function reloadAction( } } +async function syncAction( + context: CommandContext, +): Promise { + const config = context.services.config; + if (!config) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Could not retrieve configuration.', + }); + return; + } + + try { + const result = await syncSkills((msg) => + context.ui.addItem({ + type: MessageType.INFO, + text: msg, + }), + ); + + const details: string[] = []; + if (result.synced.length > 0) { + details.push( + `Synced ${result.synced.length} skill${result.synced.length > 1 ? 's' : ''} (${result.synced.join(', ')}).`, + ); + } + if (result.cleaned.length > 0) { + details.push( + `Cleaned up ${result.cleaned.length} stale link${result.cleaned.length > 1 ? 's' : ''}.`, + ); + } + if (result.conflicts.length > 0) { + details.push( + `Skipped ${result.conflicts.length} conflicting skill${result.conflicts.length > 1 ? 's' : ''} (${result.conflicts.join(', ')}).`, + ); + } + + if (details.length === 0) { + context.ui.addItem({ + type: MessageType.INFO, + text: 'Skills are already in sync. No changes needed.', + }); + } else { + context.ui.addItem({ + type: MessageType.INFO, + text: `Sync complete.\n${details.join('\n')}`, + }); + await config.reloadSkills(); + } + } catch (error) { + context.ui.addItem({ + type: MessageType.ERROR, + text: `Failed to sync skills: ${getErrorMessage(error)}`, + }); + } +} + function disableCompletion( context: CommandContext, partialArg: string, @@ -402,6 +460,13 @@ export const skillsCommand: SlashCommand = { kind: CommandKind.BUILT_IN, action: reloadAction, }, + { + name: 'sync', + description: + 'Synchronize skills from external tools (Claude, OpenCode). Usage: /skills sync', + kind: CommandKind.BUILT_IN, + action: syncAction, + }, ], action: listAction, }; diff --git a/packages/cli/src/utils/skillUtils.test.ts b/packages/cli/src/utils/skillUtils.test.ts index c769f22401a..f106d681397 100644 --- a/packages/cli/src/utils/skillUtils.test.ts +++ b/packages/cli/src/utils/skillUtils.test.ts @@ -8,15 +8,36 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import * as os from 'node:os'; -import { installSkill, linkSkill } from './skillUtils.js'; +import { installSkill, linkSkill, syncSkills } from './skillUtils.js'; +import { Storage } from '@google/gemini-cli-core'; + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + homedir: vi.fn(), + }; +}); describe('skillUtils', () => { let tempDir: string; + let mockHome: string; const projectRoot = path.resolve(__dirname, '../../../../../'); beforeEach(async () => { tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-utils-test-')); + mockHome = path.join(tempDir, 'home'); + await fs.mkdir(mockHome, { recursive: true }); vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + vi.mocked(os.homedir).mockReturnValue(mockHome); + + // Mock User Skills Dir to be inside our temp mockHome + vi.spyOn(Storage, 'getGlobalGeminiDir').mockReturnValue( + path.join(mockHome, '.gemini'), + ); + vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue( + path.join(mockHome, '.gemini', 'skills'), + ); }); afterEach(async () => { @@ -212,4 +233,91 @@ describe('skillUtils', () => { const installedExists = await fs.stat(installedPath).catch(() => null); expect(installedExists).toBeNull(); }); + + describe('syncSkills', () => { + itif(process.platform !== 'win32')( + 'should successfully sync skills from external tools', + async () => { + // Setup external Claude skills + const claudeSkillsDir = path.join(mockHome, '.claude', 'skills'); + const externalSkillDir = path.join(claudeSkillsDir, 'external-skill'); + await fs.mkdir(externalSkillDir, { recursive: true }); + await fs.writeFile( + path.join(externalSkillDir, 'SKILL.md'), + '---\nname: external-skill\ndescription: external\n---\nbody', + ); + + const result = await syncSkills(() => {}); + expect(result.synced).toContain('external-skill'); + + const linkedPath = path.join( + mockHome, + '.gemini', + 'skills', + 'external-skill', + ); + const stats = await fs.lstat(linkedPath); + expect(stats.isSymbolicLink()).toBe(true); + }, + ); + + itif(process.platform !== 'win32')( + 'should skip and report conflicts with native skills', + async () => { + // 1. Setup native skill in ~/.gemini/skills/ + const nativeSkillsDir = path.join(mockHome, '.gemini', 'skills'); + const nativeSkillDir = path.join(nativeSkillsDir, 'conflict-skill'); + await fs.mkdir(nativeSkillDir, { recursive: true }); + await fs.writeFile( + path.join(nativeSkillDir, 'SKILL.md'), + '---\nname: conflict-skill\ndescription: native\n---\nbody', + ); + + // 2. Setup external skill with same name + const claudeSkillsDir = path.join(mockHome, '.claude', 'skills'); + const externalSkillDir = path.join(claudeSkillsDir, 'conflict-skill'); + await fs.mkdir(externalSkillDir, { recursive: true }); + await fs.writeFile( + path.join(externalSkillDir, 'SKILL.md'), + '---\nname: conflict-skill\ndescription: external\n---\nbody', + ); + + const result = await syncSkills(() => {}); + expect(result.synced).not.toContain('conflict-skill'); + expect(result.conflicts).toContain('conflict-skill'); + + // Verify native folder is still there and NOT a symlink + const stats = await fs.lstat(nativeSkillDir); + expect(stats.isSymbolicLink()).toBe(false); + }, + ); + + itif(process.platform !== 'win32')( + 'should cleanup broken and conflicting symbolic links', + async () => { + const targetDir = path.join(mockHome, '.gemini', 'skills'); + await fs.mkdir(targetDir, { recursive: true }); + + // 1. Create a broken link + const brokenLinkPath = path.join(targetDir, 'broken-link'); + await fs.symlink(path.join(mockHome, 'non-existent'), brokenLinkPath); + + // 2. Create a link that now conflicts with a native skill + const conflictLinkPath = path.join(targetDir, 'now-native'); + await fs.symlink(path.join(mockHome, 'some-source'), conflictLinkPath); + + // Setup the native skill that causes conflict + const userAgentDir = path.join(mockHome, '.agents', 'skills'); + const nativeSkillDir = path.join(userAgentDir, 'now-native'); + await fs.mkdir(nativeSkillDir, { recursive: true }); + + const result = await syncSkills(() => {}); + expect(result.cleaned).toContain('broken-link'); + expect(result.cleaned).toContain('now-native'); + + await expect(fs.lstat(brokenLinkPath)).rejects.toThrow(); + await expect(fs.lstat(conflictLinkPath)).rejects.toThrow(); + }, + ); + }); }); diff --git a/packages/cli/src/utils/skillUtils.ts b/packages/cli/src/utils/skillUtils.ts index 9454db9c7c4..8cdab10d8c1 100644 --- a/packages/cli/src/utils/skillUtils.ts +++ b/packages/cli/src/utils/skillUtils.ts @@ -12,6 +12,7 @@ import { type SkillDefinition, } from '@google/gemini-cli-core'; import { cloneFromGit } from '../config/extensions/github.js'; +import { glob } from 'glob'; import extract from 'extract-zip'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; @@ -280,3 +281,178 @@ export async function uninstallSkill( await fs.rm(skillPath, { recursive: true, force: true }); return { location: skillPath }; } + +/** + * Central logic for syncing skills from external tools (Claude, OpenCode). + */ +export async function syncSkills(onLog: (msg: string) => void): Promise<{ + synced: string[]; + cleaned: string[]; + conflicts: string[]; +}> { + const home = os.homedir(); + + // 1. Identify all native skill names by scanning content in parallel + const nativePaths = [ + Storage.getUserSkillsDir(), // User Tier Standard + path.join(home, '.agents', 'skills'), // User Tier Alias + path.join(Storage.getGlobalGeminiDir(), 'extensions'), // Extension Tier root + ]; + + const existingNativeSkillNames = new Set(); + + const scanNativePath = async (p: string) => { + const stats = await fs.stat(p).catch(() => null); + if (!stats?.isDirectory()) return; + + if (p === Storage.getUserSkillsDir()) { + const entries = await fs.readdir(p, { withFileTypes: true }); + await Promise.all( + entries.map(async (entry) => { + if (entry.isDirectory() && !entry.isSymbolicLink()) { + const skills = await loadSkillsFromDir(path.join(p, entry.name)); + for (const s of skills) existingNativeSkillNames.add(s.name); + } + }), + ); + } else if (p.includes('extensions')) { + const matches = await glob(path.join(p, '**', 'skills'), { + nodir: false, + }); + await Promise.all( + matches.map(async (match) => { + const skills = await loadSkillsFromDir(match); + for (const s of skills) existingNativeSkillNames.add(s.name); + }), + ); + } else { + const skills = await loadSkillsFromDir(p); + for (const s of skills) existingNativeSkillNames.add(s.name); + } + }; + + await Promise.all(nativePaths.map(scanNativePath)); + + // 2. Define source paths for syncing + const sourcePaths = [ + path.join(home, '.claude', 'skills'), + path.join(home, '.config', 'opencode', 'skills'), + ]; + + const targetDir = Storage.getUserSkillsDir(); + await fs.mkdir(targetDir, { recursive: true }); + + const synced: string[] = []; + const cleaned: string[] = []; + const conflicts: string[] = []; + + // 3. Cleanup pass: Standardize and deduplicate links in parallel + const targetEntries = await fs.readdir(targetDir, { withFileTypes: true }); + const sourceToLinkMap = new Map(); + + const resolveTarget = async (linkPath: string) => { + const raw = await fs.readlink(linkPath).catch(() => null); + if (!raw) return null; + return path + .resolve( + path.isAbsolute(raw) ? raw : path.join(path.dirname(linkPath), raw), + ) + .replace(/\/+$/, ''); + }; + + // First pass: Map sources to official names + await Promise.all( + targetEntries.map(async (entry) => { + if (!entry.isSymbolicLink()) return; + const entryPath = path.join(targetDir, entry.name); + const resolvedPath = await resolveTarget(entryPath); + if (!resolvedPath) return; + + const linkedSkills = await loadSkillsFromDir(entryPath); + const actualSkillName = linkedSkills[0]?.name; + if (actualSkillName && entry.name === actualSkillName) { + sourceToLinkMap.set(resolvedPath, entry.name); + } + }), + ); + + // Second pass: Clean up + await Promise.all( + targetEntries.map(async (entry) => { + if (!entry.isSymbolicLink()) return; + const entryPath = path.join(targetDir, entry.name); + const targetExists = await fs.stat(entryPath).catch(() => null); + + if (!targetExists) { + onLog(`Removing broken link: ${entry.name}`); + await fs.rm(entryPath); + cleaned.push(entry.name); + return; + } + + const resolvedTargetPath = await resolveTarget(entryPath); + if (!resolvedTargetPath) return; + + const linkedSkills = await loadSkillsFromDir(entryPath); + const linkedSkillName = linkedSkills[0]?.name || entry.name; + + if (existingNativeSkillNames.has(linkedSkillName)) { + onLog( + `Removing conflicting link: ${entry.name} (conflicts with native ${linkedSkillName})`, + ); + await fs.rm(entryPath); + cleaned.push(entry.name); + } else { + const officialLinkName = sourceToLinkMap.get(resolvedTargetPath); + if (officialLinkName && entry.name !== officialLinkName) { + onLog( + `Removing redundant link: ${entry.name} (official name is ${officialLinkName})`, + ); + await fs.rm(entryPath); + cleaned.push(entry.name); + } + } + }), + ); + + // 4. Linking pass + for (const sourcePath of sourcePaths) { + const stats = await fs.stat(sourcePath).catch(() => null); + if (!stats?.isDirectory()) continue; + + const externalSkills = await loadSkillsFromDir(sourcePath); + if (externalSkills.length === 0) continue; + + onLog(`Syncing skills from ${sourcePath}...`); + for (const skill of externalSkills) { + const skillName = skill.name; + const skillSourceDir = path.dirname(skill.location); + const destPath = path.join(targetDir, skillName); + + if (existingNativeSkillNames.has(skillName)) { + conflicts.push(skillName); + continue; + } + + const destExists = await fs.lstat(destPath).catch(() => null); + if (destExists) { + if (destExists.isSymbolicLink()) { + const currentTarget = await resolveTarget(destPath); + if (currentTarget === skillSourceDir.replace(/\/+$/, '')) { + continue; // Already synced correctly + } + await fs.rm(destPath); + } else { + conflicts.push(skillName); + continue; + } + } + + onLog(`Linking skill: ${skillName}`); + await fs.symlink(skillSourceDir, destPath, 'dir'); + synced.push(skillName); + } + } + + return { synced, cleaned, conflicts }; +}