diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md index 337867c5ed3..d1094a15e20 100644 --- a/docs/cli/cli-reference.md +++ b/docs/cli/cli-reference.md @@ -99,3 +99,18 @@ See [Extensions Documentation](../extensions/index.md) for more details. | `gemini mcp list` | List all configured MCP servers | `gemini mcp list` | See [MCP Server Integration](../tools/mcp-server.md) for more details. + +## Skills management + +| Command | Description | Example | +| -------------------------------- | ------------------------------------- | ------------------------------------------------- | +| `gemini skills list` | List all discovered agent skills | `gemini skills list` | +| `gemini skills install ` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` | +| `gemini skills link ` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` | +| `gemini skills uninstall ` | Uninstall an agent skill | `gemini skills uninstall my-skill` | +| `gemini skills enable ` | Enable an agent skill | `gemini skills enable my-skill` | +| `gemini skills disable ` | Disable an agent skill | `gemini skills disable my-skill` | +| `gemini skills enable --all` | Enable all skills | `gemini skills enable --all` | +| `gemini skills disable --all` | Disable all skills | `gemini skills disable --all` | + +See [Agent Skills Documentation](./skills.md) for more details. diff --git a/docs/cli/skills.md b/docs/cli/skills.md index 34331a4c0c8..c6ef9f75ffe 100644 --- a/docs/cli/skills.md +++ b/docs/cli/skills.md @@ -52,6 +52,7 @@ locations override lower ones: **Workspace > User > Extension**. Use the `/skills` slash command to view and manage available expertise: - `/skills list` (default): Shows all discovered skills and their status. +- `/skills link `: Links agent skills from a local directory via symlink. - `/skills disable `: Prevents a specific skill from being used. - `/skills enable `: Re-enables a disabled skill. - `/skills reload`: Refreshes the list of discovered skills from all tiers. @@ -67,6 +68,13 @@ The `gemini skills` command provides management utilities: # List all discovered skills gemini skills list +# Link agent skills from a local directory via symlink +# Discovers skills (SKILL.md or */SKILL.md) and creates symlinks in ~/.gemini/skills (user) +gemini skills link /path/to/my-skills-repo + +# Link to the workspace scope (.gemini/skills) +gemini skills link /path/to/my-skills-repo --scope workspace + # Install a skill from a Git repository, local directory, or zipped skill file (.skill) # Uses the user scope by default (~/.gemini/skills) gemini skills install https://github.com/user/repo.git diff --git a/package-lock.json b/package-lock.json index b3524969365..6d48124df79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2251,6 +2251,7 @@ "integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.2", @@ -2431,6 +2432,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -2464,6 +2466,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz", "integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -2832,6 +2835,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz", "integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -2865,6 +2869,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz", "integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" @@ -2917,6 +2922,7 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz", "integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1", @@ -4122,6 +4128,7 @@ "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -4399,6 +4406,7 @@ "integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.35.0", "@typescript-eslint/types": "8.35.0", @@ -5391,6 +5399,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8400,6 +8409,7 @@ "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", @@ -8940,6 +8950,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -10541,6 +10552,7 @@ "resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz", "integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==", "license": "MIT", + "peer": true, "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.0.0", @@ -14299,6 +14311,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -14309,6 +14322,7 @@ "integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" @@ -16545,6 +16559,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -16768,7 +16783,8 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true, - "license": "0BSD" + "license": "0BSD", + "peer": true }, "node_modules/tsx": { "version": "4.20.3", @@ -16776,6 +16792,7 @@ "integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==", "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" @@ -16948,6 +16965,7 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -17155,6 +17173,7 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz", "integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==", "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", @@ -17268,6 +17287,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -17280,6 +17300,7 @@ "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "license": "MIT", + "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", @@ -17984,6 +18005,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -18278,6 +18300,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, diff --git a/packages/cli/src/commands/skills.tsx b/packages/cli/src/commands/skills.tsx index 1559cf42ffc..8a51c4150e5 100644 --- a/packages/cli/src/commands/skills.tsx +++ b/packages/cli/src/commands/skills.tsx @@ -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), diff --git a/packages/cli/src/commands/skills/link.test.ts b/packages/cli/src/commands/skills/link.test.ts new file mode 100644 index 00000000000..404c1d9f667 --- /dev/null +++ b/packages/cli/src/commands/skills/link.test.ts @@ -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 '); + 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); + }); +}); diff --git a/packages/cli/src/commands/skills/link.ts b/packages/cli/src/commands/skills/link.ts new file mode 100644 index 00000000000..354b86133ca --- /dev/null +++ b/packages/cli/src/commands/skills/link.ts @@ -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 ', + 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(); + }, +}; diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts index 27b8e9a9044..9c3ea83bb6a 100644 --- a/packages/cli/src/config/extensions/consent.ts +++ b/packages/cli/src/config/extensions/consent.ts @@ -28,14 +28,19 @@ export async function skillsConsentString( skills: SkillDefinition[], source: string, targetDir?: string, + isLink = false, ): Promise { + 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); diff --git a/packages/cli/src/ui/commands/skillsCommand.test.ts b/packages/cli/src/ui/commands/skillsCommand.test.ts index 3a826399238..89f690e1436 100644 --- a/packages/cli/src/ui/commands/skillsCommand.test.ts +++ b/packages/cli/src/ui/commands/skillsCommand.test.ts @@ -17,6 +17,27 @@ import { type MergedSettings, } from '../../config/settings.js'; +vi.mock('../../utils/skillUtils.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + linkSkill: vi.fn(), + }; +}); + +vi.mock('../../config/extensions/consent.js', async (importOriginal) => { + const actual = + await importOriginal(); + 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(); @@ -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 [--scope user|workspace]', + }), + ); + }); + }); + describe('disable/enable', () => { beforeEach(() => { ( diff --git a/packages/cli/src/ui/commands/skillsCommand.ts b/packages/cli/src/ui/commands/skillsCommand.ts index 74372d21795..e8e3a7324f3 100644 --- a/packages/cli/src/ui/commands/skillsCommand.ts +++ b/packages/cli/src/ui/commands/skillsCommand.ts @@ -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 { + const parts = args.trim().split(/\s+/); + const sourcePath = parts[0]; + + if (!sourcePath) { + context.ui.addItem({ + type: MessageType.ERROR, + text: 'Usage: /skills link [--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 [--scope user|workspace]', + kind: CommandKind.BUILT_IN, + action: linkAction, + }, { name: 'disable', description: 'Disable a skill by name. Usage: /skills disable ', diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 283cc9b6e12..c01bee21d5e 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -83,6 +83,12 @@ export interface CommandContext { extensionsUpdateState: Map; 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; }; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index a8bb8ee2bf5..acd7749d5db 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -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, diff --git a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts index ae442c923fd..aca12dc3069 100644 --- a/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts +++ b/packages/cli/src/ui/noninteractive/nonInteractiveUi.ts @@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] { extensionsUpdateState: new Map(), dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {}, addConfirmUpdateExtensionRequest: (_request) => {}, + setConfirmationRequest: (_request) => {}, removeComponent: () => {}, toggleBackgroundShell: () => {}, }; diff --git a/packages/cli/src/utils/skillUtils.test.ts b/packages/cli/src/utils/skillUtils.test.ts index 5f984711128..432e1235eec 100644 --- a/packages/cli/src/utils/skillUtils.test.ts +++ b/packages/cli/src/utils/skillUtils.test.ts @@ -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'); diff --git a/packages/cli/src/utils/skillUtils.ts b/packages/cli/src/utils/skillUtils.ts index 43cae2733c6..9454db9c7c4 100644 --- a/packages/cli/src/utils/skillUtils.ts +++ b/packages/cli/src/utils/skillUtils.ts @@ -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 = () => Promise.resolve(true), +): Promise> { + 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(); + 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. */ diff --git a/packages/core/src/skills/skillLoader.test.ts b/packages/core/src/skills/skillLoader.test.ts index dd0564be064..3fe88c3443d 100644 --- a/packages/core/src/skills/skillLoader.test.ts +++ b/packages/core/src/skills/skillLoader.test.ts @@ -254,4 +254,21 @@ description:no-space-desc expect(skills[0].name).toBe('no-space-name'); expect(skills[0].description).toBe('no-space-desc'); }); + + it('should sanitize skill names containing invalid filename characters', async () => { + const skillFile = path.join(testRootDir, 'SKILL.md'); + await fs.writeFile( + skillFile, + `--- +name: gke:prs-troubleshooter +description: Test sanitization +--- +`, + ); + + const skills = await loadSkillsFromDir(testRootDir); + + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('gke-prs-troubleshooter'); + }); }); diff --git a/packages/core/src/skills/skillLoader.ts b/packages/core/src/skills/skillLoader.ts index 4bbf0823f76..1293dab702d 100644 --- a/packages/core/src/skills/skillLoader.ts +++ b/packages/core/src/skills/skillLoader.ts @@ -121,10 +121,12 @@ export async function loadSkillsFromDir( return []; } - const skillFiles = await glob(['SKILL.md', '*/SKILL.md'], { + const pattern = ['SKILL.md', '*/SKILL.md']; + const skillFiles = await glob(pattern, { cwd: absoluteSearchPath, absolute: true, nodir: true, + ignore: ['**/node_modules/**', '**/.git/**'], }); for (const skillFile of skillFiles) { @@ -171,8 +173,11 @@ export async function loadSkillFromFile( return null; } + // Sanitize name for use as a filename/directory name (e.g. replace ':' with '-') + const sanitizedName = frontmatter.name.replace(/[:\\/<>*?"|]/g, '-'); + return { - name: frontmatter.name, + name: sanitizedName, description: frontmatter.description, location: filePath, body: match[2]?.trim() ?? '',