mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-15 16:41:11 -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();
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
(
|
||||
|
||||
@@ -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>',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
||||
extensionsUpdateState: new Map(),
|
||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||
setConfirmationRequest: (_request) => {},
|
||||
removeComponent: () => {},
|
||||
toggleBackgroundShell: () => {},
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user