Skip to content
Closed
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
81 changes: 80 additions & 1 deletion packages/cli/src/ui/commands/skillsCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vi.mock('../../utils/skillUtils.js', async (importOriginal) => {
return {
...actual,
linkSkill: vi.fn(),
syncSkills: vi.fn(),
};
});

Expand All @@ -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 =
Expand Down Expand Up @@ -80,6 +81,7 @@ describe('skillsCommand', () => {
),
}),
getContentGenerator: vi.fn(),
reloadSkills: vi.fn().mockResolvedValue(undefined),
} as unknown as Config,
settings: {
merged: createTestMergedSettings({ skills: { disabled: [] } }),
Expand Down Expand Up @@ -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',
}),
);
});
});
});
65 changes: 65 additions & 0 deletions packages/cli/src/ui/commands/skillsCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -330,6 +331,63 @@ async function reloadAction(
}
}

async function syncAction(
context: CommandContext,
): Promise<void | SlashCommandActionReturn> {
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,
Expand Down Expand Up @@ -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,
};
110 changes: 109 additions & 1 deletion packages/cli/src/utils/skillUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('node:os')>();
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 () => {
Expand Down Expand Up @@ -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();
},
);
});
});
Loading