feat(skills): implement linking for agent skills (#18295)

This commit is contained in:
Grant McCloskey
2026-02-04 14:11:01 -08:00
committed by GitHub
parent 821355c429
commit a3af4a8cae
16 changed files with 584 additions and 8 deletions

View File

@@ -9,6 +9,7 @@ import { listCommand } from './skills/list.js';
import { enableCommand } from './skills/enable.js';
import { disableCommand } from './skills/disable.js';
import { installCommand } from './skills/install.js';
import { linkCommand } from './skills/link.js';
import { uninstallCommand } from './skills/uninstall.js';
import { initializeOutputListenersAndFlush } from '../gemini.js';
import { defer } from '../deferred.js';
@@ -27,6 +28,7 @@ export const skillsCommand: CommandModule = {
.command(defer(enableCommand, 'skills'))
.command(defer(disableCommand, 'skills'))
.command(defer(installCommand, 'skills'))
.command(defer(linkCommand, 'skills'))
.command(defer(uninstallCommand, 'skills'))
.demandCommand(1, 'You need at least one command before continuing.')
.version(false),

View File

@@ -0,0 +1,69 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { handleLink, linkCommand } from './link.js';
const mockLinkSkill = vi.hoisted(() => vi.fn());
const mockRequestConsentNonInteractive = vi.hoisted(() => vi.fn());
const mockSkillsConsentString = vi.hoisted(() => vi.fn());
vi.mock('../../utils/skillUtils.js', () => ({
linkSkill: mockLinkSkill,
}));
vi.mock('@google/gemini-cli-core', () => ({
debugLogger: { log: vi.fn(), error: vi.fn() },
}));
vi.mock('../../config/extensions/consent.js', () => ({
requestConsentNonInteractive: mockRequestConsentNonInteractive,
skillsConsentString: mockSkillsConsentString,
}));
import { debugLogger } from '@google/gemini-cli-core';
describe('skills link command', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(process, 'exit').mockImplementation(() => undefined as never);
});
describe('linkCommand', () => {
it('should have correct command and describe', () => {
expect(linkCommand.command).toBe('link <path>');
expect(linkCommand.describe).toContain('Links an agent skill');
});
});
it('should call linkSkill with correct arguments', async () => {
const sourcePath = '/source/path';
mockLinkSkill.mockResolvedValue([
{ name: 'test-skill', location: '/dest/path' },
]);
await handleLink({ path: sourcePath, scope: 'user' });
expect(mockLinkSkill).toHaveBeenCalledWith(
sourcePath,
'user',
expect.any(Function),
expect.any(Function),
);
expect(debugLogger.log).toHaveBeenCalledWith(
expect.stringContaining('Successfully linked skills'),
);
});
it('should handle linkSkill failure', async () => {
mockLinkSkill.mockRejectedValue(new Error('Link failed'));
await handleLink({ path: '/some/path' });
expect(debugLogger.error).toHaveBeenCalledWith('Link failed');
expect(process.exit).toHaveBeenCalledWith(1);
});
});

View File

@@ -0,0 +1,93 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { CommandModule } from 'yargs';
import { debugLogger } from '@google/gemini-cli-core';
import chalk from 'chalk';
import { getErrorMessage } from '../../utils/errors.js';
import { exitCli } from '../utils.js';
import {
requestConsentNonInteractive,
skillsConsentString,
} from '../../config/extensions/consent.js';
import { linkSkill } from '../../utils/skillUtils.js';
interface LinkArgs {
path: string;
scope?: 'user' | 'workspace';
consent?: boolean;
}
export async function handleLink(args: LinkArgs) {
try {
const { scope = 'user', consent } = args;
await linkSkill(
args.path,
scope,
(msg) => debugLogger.log(msg),
async (skills, targetDir) => {
const consentString = await skillsConsentString(
skills,
args.path,
targetDir,
true,
);
if (consent) {
debugLogger.log('You have consented to the following:');
debugLogger.log(consentString);
return true;
}
return requestConsentNonInteractive(consentString);
},
);
debugLogger.log(chalk.green('\nSuccessfully linked skills.'));
} catch (error) {
debugLogger.error(getErrorMessage(error));
await exitCli(1);
}
}
export const linkCommand: CommandModule = {
command: 'link <path>',
describe:
'Links an agent skill from a local path. Updates to the source will be reflected immediately.',
builder: (yargs) =>
yargs
.positional('path', {
describe: 'The local path of the skill to link.',
type: 'string',
demandOption: true,
})
.option('scope', {
describe:
'The scope to link the skill into. Defaults to "user" (global).',
choices: ['user', 'workspace'],
default: 'user',
})
.option('consent', {
describe:
'Acknowledge the security risks of linking a skill and skip the confirmation prompt.',
type: 'boolean',
default: false,
})
.check((argv) => {
if (!argv.path) {
throw new Error('The path argument must be provided.');
}
return true;
}),
handler: async (argv) => {
await handleLink({
path: argv['path'] as string,
scope: argv['scope'] as 'user' | 'workspace',
consent: argv['consent'] as boolean | undefined,
});
await exitCli();
},
};

View File

@@ -28,14 +28,19 @@ export async function skillsConsentString(
skills: SkillDefinition[],
source: string,
targetDir?: string,
isLink = false,
): Promise<string> {
const action = isLink ? 'Linking' : 'Installing';
const output: string[] = [];
output.push(`Installing agent skill(s) from "${source}".`);
output.push('\nThe following agent skill(s) will be installed:\n');
output.push(`${action} agent skill(s) from "${source}".`);
output.push(
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
);
output.push(...(await renderSkillsList(skills)));
if (targetDir) {
output.push(`Install Destination: ${targetDir}`);
const destLabel = isLink ? 'Link' : 'Install';
output.push(`${destLabel} Destination: ${targetDir}`);
}
output.push('\n' + SKILLS_WARNING_MESSAGE);

View File

@@ -17,6 +17,27 @@ import {
type MergedSettings,
} from '../../config/settings.js';
vi.mock('../../utils/skillUtils.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../utils/skillUtils.js')>();
return {
...actual,
linkSkill: vi.fn(),
};
});
vi.mock('../../config/extensions/consent.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/extensions/consent.js')>();
return {
...actual,
requestConsentInteractive: vi.fn().mockResolvedValue(true),
skillsConsentString: vi.fn().mockResolvedValue('Mock Consent'),
};
});
import { linkSkill } from '../../utils/skillUtils.js';
vi.mock('../../config/settings.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/settings.js')>();
@@ -185,6 +206,80 @@ describe('skillsCommand', () => {
expect(lastCall.skills).toHaveLength(2);
});
describe('link', () => {
it('should link a skill successfully', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
vi.mocked(linkSkill).mockResolvedValue([
{ name: 'test-skill', location: '/path' },
]);
await linkCmd.action!(context, '/some/path');
expect(linkSkill).toHaveBeenCalledWith(
'/some/path',
'user',
expect.any(Function),
expect.any(Function),
);
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.INFO,
text: 'Successfully linked skills from "/some/path" (user).',
}),
);
});
it('should link a skill with workspace scope', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
vi.mocked(linkSkill).mockResolvedValue([
{ name: 'test-skill', location: '/path' },
]);
await linkCmd.action!(context, '/some/path --scope workspace');
expect(linkSkill).toHaveBeenCalledWith(
'/some/path',
'workspace',
expect.any(Function),
expect.any(Function),
);
});
it('should show error if link fails', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
vi.mocked(linkSkill).mockRejectedValue(new Error('Link failed'));
await linkCmd.action!(context, '/some/path');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Failed to link skills: Link failed',
}),
);
});
it('should show error if path is missing', async () => {
const linkCmd = skillsCommand.subCommands!.find(
(s) => s.name === 'link',
)!;
await linkCmd.action!(context, '');
expect(context.ui.addItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Usage: /skills link <path> [--scope user|workspace]',
}),
);
});
});
describe('disable/enable', () => {
beforeEach(() => {
(

View File

@@ -16,10 +16,18 @@ import {
MessageType,
} from '../types.js';
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
import { getErrorMessage } from '../../utils/errors.js';
import { getAdminErrorMessage } from '@google/gemini-cli-core';
import { renderSkillActionFeedback } from '../../utils/skillUtils.js';
import {
linkSkill,
renderSkillActionFeedback,
} from '../../utils/skillUtils.js';
import { SettingScope } from '../../config/settings.js';
import {
requestConsentInteractive,
skillsConsentString,
} from '../../config/extensions/consent.js';
async function listAction(
context: CommandContext,
@@ -68,6 +76,69 @@ async function listAction(
context.ui.addItem(skillsListItem);
}
async function linkAction(
context: CommandContext,
args: string,
): Promise<void | SlashCommandActionReturn> {
const parts = args.trim().split(/\s+/);
const sourcePath = parts[0];
if (!sourcePath) {
context.ui.addItem({
type: MessageType.ERROR,
text: 'Usage: /skills link <path> [--scope user|workspace]',
});
return;
}
let scopeArg = 'user';
if (parts.length >= 3 && parts[1] === '--scope') {
scopeArg = parts[2];
} else if (parts.length >= 2 && parts[1].startsWith('--scope=')) {
scopeArg = parts[1].split('=')[1];
}
const scope = scopeArg === 'workspace' ? 'workspace' : 'user';
try {
await linkSkill(
sourcePath,
scope,
(msg) =>
context.ui.addItem({
type: MessageType.INFO,
text: msg,
}),
async (skills, targetDir) => {
const consentString = await skillsConsentString(
skills,
sourcePath,
targetDir,
true,
);
return requestConsentInteractive(
consentString,
context.ui.setConfirmationRequest.bind(context.ui),
);
},
);
context.ui.addItem({
type: MessageType.INFO,
text: `Successfully linked skills from "${sourcePath}" (${scope}).`,
});
if (context.services.config) {
await context.services.config.reloadSkills();
}
} catch (error) {
context.ui.addItem({
type: MessageType.ERROR,
text: `Failed to link skills: ${getErrorMessage(error)}`,
});
}
}
async function disableAction(
context: CommandContext,
args: string,
@@ -301,6 +372,13 @@ export const skillsCommand: SlashCommand = {
kind: CommandKind.BUILT_IN,
action: listAction,
},
{
name: 'link',
description:
'Link an agent skill from a local path. Usage: /skills link <path> [--scope user|workspace]',
kind: CommandKind.BUILT_IN,
action: linkAction,
},
{
name: 'disable',
description: 'Disable a skill by name. Usage: /skills disable <name>',

View File

@@ -83,6 +83,12 @@ export interface CommandContext {
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => void;
/**
* Sets a confirmation request to be displayed to the user.
*
* @param value The confirmation request details.
*/
setConfirmationRequest: (value: ConfirmationRequest) => void;
removeComponent: () => void;
toggleBackgroundShell: () => void;
};

View File

@@ -237,6 +237,7 @@ export const useSlashCommandProcessor = (
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
addConfirmUpdateExtensionRequest:
actions.addConfirmUpdateExtensionRequest,
setConfirmationRequest,
removeComponent: () => setCustomDialog(null),
toggleBackgroundShell: actions.toggleBackgroundShell,
},
@@ -258,6 +259,7 @@ export const useSlashCommandProcessor = (
actions,
pendingItem,
setPendingItem,
setConfirmationRequest,
toggleVimEnabled,
sessionShellAllowlist,
reloadCommands,

View File

@@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
extensionsUpdateState: new Map(),
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
addConfirmUpdateExtensionRequest: (_request) => {},
setConfirmationRequest: (_request) => {},
removeComponent: () => {},
toggleBackgroundShell: () => {},
};

View File

@@ -8,7 +8,7 @@ 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 } from './skillUtils.js';
import { installSkill, linkSkill } from './skillUtils.js';
describe('skillUtils', () => {
let tempDir: string;
@@ -24,6 +24,94 @@ describe('skillUtils', () => {
vi.restoreAllMocks();
});
describe('linkSkill', () => {
it('should successfully link from a local directory', async () => {
// Create a mock skill directory
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
expect(skills.length).toBe(1);
expect(skills[0].name).toBe('test-skill');
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const stats = await fs.lstat(linkedPath);
expect(stats.isSymbolicLink()).toBe(true);
const linkTarget = await fs.readlink(linkedPath);
expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir));
});
it('should overwrite existing skill at destination', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const targetDir = path.join(tempDir, '.gemini/skills');
await fs.mkdir(targetDir, { recursive: true });
const existingPath = path.join(targetDir, 'test-skill');
await fs.mkdir(existingPath);
const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {});
expect(skills.length).toBe(1);
const stats = await fs.lstat(existingPath);
expect(stats.isSymbolicLink()).toBe(true);
});
it('should abort linking if consent is rejected', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillSubDir = path.join(mockSkillSourceDir, 'test-skill');
await fs.mkdir(skillSubDir, { recursive: true });
await fs.writeFile(
path.join(skillSubDir, 'SKILL.md'),
'---\nname: test-skill\ndescription: test\n---\nbody',
);
const requestConsent = vi.fn().mockResolvedValue(false);
await expect(
linkSkill(mockSkillSourceDir, 'workspace', () => {}, requestConsent),
).rejects.toThrow('Skill linking cancelled by user.');
expect(requestConsent).toHaveBeenCalled();
// Verify it was NOT linked
const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill');
const exists = await fs.lstat(linkedPath).catch(() => null);
expect(exists).toBeNull();
});
it('should throw error if multiple skills with same name are discovered', async () => {
const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source');
const skillDir1 = path.join(mockSkillSourceDir, 'skill1');
const skillDir2 = path.join(mockSkillSourceDir, 'skill2');
await fs.mkdir(skillDir1, { recursive: true });
await fs.mkdir(skillDir2, { recursive: true });
await fs.writeFile(
path.join(skillDir1, 'SKILL.md'),
'---\nname: duplicate-skill\ndescription: desc1\n---\nbody1',
);
await fs.writeFile(
path.join(skillDir2, 'SKILL.md'),
'---\nname: duplicate-skill\ndescription: desc2\n---\nbody2',
);
await expect(
linkSkill(mockSkillSourceDir, 'workspace', () => {}),
).rejects.toThrow('Duplicate skill name "duplicate-skill" found');
});
});
it('should successfully install from a .skill file', async () => {
const skillPath = path.join(projectRoot, 'weather-skill.skill');

View File

@@ -186,6 +186,75 @@ export async function installSkill(
}
}
/**
* Central logic for linking a skill from a local path via symlink.
*/
export async function linkSkill(
source: string,
scope: 'user' | 'workspace',
onLog: (msg: string) => void,
requestConsent: (
skills: SkillDefinition[],
targetDir: string,
) => Promise<boolean> = () => Promise.resolve(true),
): Promise<Array<{ name: string; location: string }>> {
const sourcePath = path.resolve(source);
onLog(`Searching for skills in ${sourcePath}...`);
const skills = await loadSkillsFromDir(sourcePath);
if (skills.length === 0) {
throw new Error(
`No valid skills found in "${sourcePath}". Ensure a SKILL.md file exists with valid frontmatter.`,
);
}
// Check for internal name collisions
const seenNames = new Map<string, string>();
for (const skill of skills) {
if (seenNames.has(skill.name)) {
throw new Error(
`Duplicate skill name "${skill.name}" found at multiple locations:\n - ${seenNames.get(skill.name)}\n - ${skill.location}`,
);
}
seenNames.set(skill.name, skill.location);
}
const workspaceDir = process.cwd();
const storage = new Storage(workspaceDir);
const targetDir =
scope === 'workspace'
? storage.getProjectSkillsDir()
: Storage.getUserSkillsDir();
if (!(await requestConsent(skills, targetDir))) {
throw new Error('Skill linking cancelled by user.');
}
await fs.mkdir(targetDir, { recursive: true });
const linkedSkills: Array<{ name: string; location: string }> = [];
for (const skill of skills) {
const skillName = skill.name;
const skillSourceDir = path.dirname(skill.location);
const destPath = path.join(targetDir, skillName);
const exists = await fs.lstat(destPath).catch(() => null);
if (exists) {
onLog(
`Skill "${skillName}" already exists at destination. Overwriting...`,
);
await fs.rm(destPath, { recursive: true, force: true });
}
await fs.symlink(skillSourceDir, destPath, 'dir');
linkedSkills.push({ name: skillName, location: destPath });
}
return linkedSkills;
}
/**
* Central logic for uninstalling a skill by name.
*/