mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-19 18:40:57 -07:00
feat(skills): implement linking for agent skills (#18295)
This commit is contained in:
@@ -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),
|
||||
|
||||
69
packages/cli/src/commands/skills/link.test.ts
Normal file
69
packages/cli/src/commands/skills/link.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
93
packages/cli/src/commands/skills/link.ts
Normal file
93
packages/cli/src/commands/skills/link.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user