mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-25 20:44:46 -07:00
feat(skills): implement linking for agent skills (#18295)
This commit is contained in:
@@ -99,3 +99,18 @@ See [Extensions Documentation](../extensions/index.md) for more details.
|
|||||||
| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` |
|
| `gemini mcp list` | List all configured MCP servers | `gemini mcp list` |
|
||||||
|
|
||||||
See [MCP Server Integration](../tools/mcp-server.md) for more details.
|
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 <source>` | Install skill from Git, path, or file | `gemini skills install https://github.com/u/repo` |
|
||||||
|
| `gemini skills link <path>` | Link local agent skills via symlink | `gemini skills link /path/to/my-skills` |
|
||||||
|
| `gemini skills uninstall <name>` | Uninstall an agent skill | `gemini skills uninstall my-skill` |
|
||||||
|
| `gemini skills enable <name>` | Enable an agent skill | `gemini skills enable my-skill` |
|
||||||
|
| `gemini skills disable <name>` | 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.
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ locations override lower ones: **Workspace > User > Extension**.
|
|||||||
Use the `/skills` slash command to view and manage available expertise:
|
Use the `/skills` slash command to view and manage available expertise:
|
||||||
|
|
||||||
- `/skills list` (default): Shows all discovered skills and their status.
|
- `/skills list` (default): Shows all discovered skills and their status.
|
||||||
|
- `/skills link <path>`: Links agent skills from a local directory via symlink.
|
||||||
- `/skills disable <name>`: Prevents a specific skill from being used.
|
- `/skills disable <name>`: Prevents a specific skill from being used.
|
||||||
- `/skills enable <name>`: Re-enables a disabled skill.
|
- `/skills enable <name>`: Re-enables a disabled skill.
|
||||||
- `/skills reload`: Refreshes the list of discovered skills from all tiers.
|
- `/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
|
# List all discovered skills
|
||||||
gemini skills list
|
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)
|
# Install a skill from a Git repository, local directory, or zipped skill file (.skill)
|
||||||
# Uses the user scope by default (~/.gemini/skills)
|
# Uses the user scope by default (~/.gemini/skills)
|
||||||
gemini skills install https://github.com/user/repo.git
|
gemini skills install https://github.com/user/repo.git
|
||||||
|
|||||||
Generated
+24
-1
@@ -2251,6 +2251,7 @@
|
|||||||
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
"integrity": "sha512-t54CUOsFMappY1Jbzb7fetWeO0n6K0k/4+/ZpkS+3Joz8I4VcvY9OiEBFRYISqaI2fq5sCiPtAjRDOzVYG8m+Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-token": "^6.0.0",
|
"@octokit/auth-token": "^6.0.0",
|
||||||
"@octokit/graphql": "^9.0.2",
|
"@octokit/graphql": "^9.0.2",
|
||||||
@@ -2431,6 +2432,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.0.0"
|
"node": ">=8.0.0"
|
||||||
}
|
}
|
||||||
@@ -2464,6 +2466,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.0.1.tgz",
|
||||||
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
"integrity": "sha512-MaZk9SJIDgo1peKevlbhP6+IwIiNPNmswNL4AF0WaQJLbHXjr9SrZMgS12+iqr9ToV4ZVosCcc0f8Rg67LXjxw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
},
|
},
|
||||||
@@ -2832,6 +2835,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.0.1.tgz",
|
||||||
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
"integrity": "sha512-dZOB3R6zvBwDKnHDTB4X1xtMArB/d324VsbiPkX/Yu0Q8T2xceRthoIVFhJdvgVM2QhGVUyX9tzwiNxGtoBJUw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||||
@@ -2865,6 +2869,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.0.1.tgz",
|
||||||
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
"integrity": "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/resources": "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",
|
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.0.1.tgz",
|
||||||
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
"integrity": "sha512-xYLlvk/xdScGx1aEqvxLwf6sXQLXCjk3/1SQT9X9AoN5rXRhkdvIFShuNNmtTEPRBqcsMbS4p/gJLNI2wXaDuQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opentelemetry/core": "2.0.1",
|
"@opentelemetry/core": "2.0.1",
|
||||||
"@opentelemetry/resources": "2.0.1",
|
"@opentelemetry/resources": "2.0.1",
|
||||||
@@ -4122,6 +4128,7 @@
|
|||||||
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
"integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"csstype": "^3.0.2"
|
"csstype": "^3.0.2"
|
||||||
}
|
}
|
||||||
@@ -4399,6 +4406,7 @@
|
|||||||
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.35.0",
|
"@typescript-eslint/scope-manager": "8.35.0",
|
||||||
"@typescript-eslint/types": "8.35.0",
|
"@typescript-eslint/types": "8.35.0",
|
||||||
@@ -5391,6 +5399,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -8400,6 +8409,7 @@
|
|||||||
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
"integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.12.1",
|
"@eslint-community/regexpp": "^4.12.1",
|
||||||
@@ -8940,6 +8950,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -10541,6 +10552,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
"resolved": "https://registry.npmjs.org/@jrichman/ink/-/ink-6.4.8.tgz",
|
||||||
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
"integrity": "sha512-v0thcXIKl9hqF/1w4HqA6MKxIcMoWSP3YtEZIAA+eeJngXpN5lGnMkb6rllB7FnOdwyEyYaFTcu1ZVr4/JZpWQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alcalzone/ansi-tokenize": "^0.2.1",
|
"@alcalzone/ansi-tokenize": "^0.2.1",
|
||||||
"ansi-escapes": "^7.0.0",
|
"ansi-escapes": "^7.0.0",
|
||||||
@@ -14299,6 +14311,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14309,6 +14322,7 @@
|
|||||||
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
"integrity": "sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"shell-quote": "^1.6.1",
|
"shell-quote": "^1.6.1",
|
||||||
"ws": "^7"
|
"ws": "^7"
|
||||||
@@ -16545,6 +16559,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -16768,7 +16783,8 @@
|
|||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "0BSD"
|
"license": "0BSD",
|
||||||
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.20.3",
|
"version": "4.20.3",
|
||||||
@@ -16776,6 +16792,7 @@
|
|||||||
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
"integrity": "sha512-qjbnuR9Tr+FJOMBqJCW5ehvIo/buZq7vH7qD7JziU98h6l3qGy0a/yPFjwO+y0/T7GFpNgNAvEcPPVfyT8rrPQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.25.0",
|
"esbuild": "~0.25.0",
|
||||||
"get-tsconfig": "^4.7.5"
|
"get-tsconfig": "^4.7.5"
|
||||||
@@ -16948,6 +16965,7 @@
|
|||||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
@@ -17155,6 +17173,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
|
||||||
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "^0.25.0",
|
"esbuild": "^0.25.0",
|
||||||
"fdir": "^6.5.0",
|
"fdir": "^6.5.0",
|
||||||
@@ -17268,6 +17287,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -17280,6 +17300,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
|
||||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/expect": "3.2.4",
|
"@vitest/expect": "3.2.4",
|
||||||
@@ -17984,6 +18005,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
|
||||||
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
@@ -18278,6 +18300,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { listCommand } from './skills/list.js';
|
|||||||
import { enableCommand } from './skills/enable.js';
|
import { enableCommand } from './skills/enable.js';
|
||||||
import { disableCommand } from './skills/disable.js';
|
import { disableCommand } from './skills/disable.js';
|
||||||
import { installCommand } from './skills/install.js';
|
import { installCommand } from './skills/install.js';
|
||||||
|
import { linkCommand } from './skills/link.js';
|
||||||
import { uninstallCommand } from './skills/uninstall.js';
|
import { uninstallCommand } from './skills/uninstall.js';
|
||||||
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
import { initializeOutputListenersAndFlush } from '../gemini.js';
|
||||||
import { defer } from '../deferred.js';
|
import { defer } from '../deferred.js';
|
||||||
@@ -27,6 +28,7 @@ export const skillsCommand: CommandModule = {
|
|||||||
.command(defer(enableCommand, 'skills'))
|
.command(defer(enableCommand, 'skills'))
|
||||||
.command(defer(disableCommand, 'skills'))
|
.command(defer(disableCommand, 'skills'))
|
||||||
.command(defer(installCommand, 'skills'))
|
.command(defer(installCommand, 'skills'))
|
||||||
|
.command(defer(linkCommand, 'skills'))
|
||||||
.command(defer(uninstallCommand, 'skills'))
|
.command(defer(uninstallCommand, 'skills'))
|
||||||
.demandCommand(1, 'You need at least one command before continuing.')
|
.demandCommand(1, 'You need at least one command before continuing.')
|
||||||
.version(false),
|
.version(false),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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[],
|
skills: SkillDefinition[],
|
||||||
source: string,
|
source: string,
|
||||||
targetDir?: string,
|
targetDir?: string,
|
||||||
|
isLink = false,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
const action = isLink ? 'Linking' : 'Installing';
|
||||||
const output: string[] = [];
|
const output: string[] = [];
|
||||||
output.push(`Installing agent skill(s) from "${source}".`);
|
output.push(`${action} agent skill(s) from "${source}".`);
|
||||||
output.push('\nThe following agent skill(s) will be installed:\n');
|
output.push(
|
||||||
|
`\nThe following agent skill(s) will be ${action.toLowerCase()}:\n`,
|
||||||
|
);
|
||||||
output.push(...(await renderSkillsList(skills)));
|
output.push(...(await renderSkillsList(skills)));
|
||||||
|
|
||||||
if (targetDir) {
|
if (targetDir) {
|
||||||
output.push(`Install Destination: ${targetDir}`);
|
const destLabel = isLink ? 'Link' : 'Install';
|
||||||
|
output.push(`${destLabel} Destination: ${targetDir}`);
|
||||||
}
|
}
|
||||||
output.push('\n' + SKILLS_WARNING_MESSAGE);
|
output.push('\n' + SKILLS_WARNING_MESSAGE);
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,27 @@ import {
|
|||||||
type MergedSettings,
|
type MergedSettings,
|
||||||
} from '../../config/settings.js';
|
} 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) => {
|
vi.mock('../../config/settings.js', async (importOriginal) => {
|
||||||
const actual =
|
const actual =
|
||||||
await importOriginal<typeof import('../../config/settings.js')>();
|
await importOriginal<typeof import('../../config/settings.js')>();
|
||||||
@@ -185,6 +206,80 @@ describe('skillsCommand', () => {
|
|||||||
expect(lastCall.skills).toHaveLength(2);
|
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', () => {
|
describe('disable/enable', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -16,10 +16,18 @@ import {
|
|||||||
MessageType,
|
MessageType,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
import { disableSkill, enableSkill } from '../../utils/skillSettings.js';
|
||||||
|
import { getErrorMessage } from '../../utils/errors.js';
|
||||||
|
|
||||||
import { getAdminErrorMessage } from '@google/gemini-cli-core';
|
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 { SettingScope } from '../../config/settings.js';
|
||||||
|
import {
|
||||||
|
requestConsentInteractive,
|
||||||
|
skillsConsentString,
|
||||||
|
} from '../../config/extensions/consent.js';
|
||||||
|
|
||||||
async function listAction(
|
async function listAction(
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
@@ -68,6 +76,69 @@ async function listAction(
|
|||||||
context.ui.addItem(skillsListItem);
|
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(
|
async function disableAction(
|
||||||
context: CommandContext,
|
context: CommandContext,
|
||||||
args: string,
|
args: string,
|
||||||
@@ -301,6 +372,13 @@ export const skillsCommand: SlashCommand = {
|
|||||||
kind: CommandKind.BUILT_IN,
|
kind: CommandKind.BUILT_IN,
|
||||||
action: listAction,
|
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',
|
name: 'disable',
|
||||||
description: 'Disable a skill by name. Usage: /skills disable <name>',
|
description: 'Disable a skill by name. Usage: /skills disable <name>',
|
||||||
|
|||||||
@@ -83,6 +83,12 @@ export interface CommandContext {
|
|||||||
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
extensionsUpdateState: Map<string, ExtensionUpdateStatus>;
|
||||||
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
dispatchExtensionStateUpdate: (action: ExtensionUpdateAction) => void;
|
||||||
addConfirmUpdateExtensionRequest: (value: ConfirmationRequest) => 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;
|
removeComponent: () => void;
|
||||||
toggleBackgroundShell: () => void;
|
toggleBackgroundShell: () => void;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -237,6 +237,7 @@ export const useSlashCommandProcessor = (
|
|||||||
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
dispatchExtensionStateUpdate: actions.dispatchExtensionStateUpdate,
|
||||||
addConfirmUpdateExtensionRequest:
|
addConfirmUpdateExtensionRequest:
|
||||||
actions.addConfirmUpdateExtensionRequest,
|
actions.addConfirmUpdateExtensionRequest,
|
||||||
|
setConfirmationRequest,
|
||||||
removeComponent: () => setCustomDialog(null),
|
removeComponent: () => setCustomDialog(null),
|
||||||
toggleBackgroundShell: actions.toggleBackgroundShell,
|
toggleBackgroundShell: actions.toggleBackgroundShell,
|
||||||
},
|
},
|
||||||
@@ -258,6 +259,7 @@ export const useSlashCommandProcessor = (
|
|||||||
actions,
|
actions,
|
||||||
pendingItem,
|
pendingItem,
|
||||||
setPendingItem,
|
setPendingItem,
|
||||||
|
setConfirmationRequest,
|
||||||
toggleVimEnabled,
|
toggleVimEnabled,
|
||||||
sessionShellAllowlist,
|
sessionShellAllowlist,
|
||||||
reloadCommands,
|
reloadCommands,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export function createNonInteractiveUI(): CommandContext['ui'] {
|
|||||||
extensionsUpdateState: new Map(),
|
extensionsUpdateState: new Map(),
|
||||||
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
dispatchExtensionStateUpdate: (_action: ExtensionUpdateAction) => {},
|
||||||
addConfirmUpdateExtensionRequest: (_request) => {},
|
addConfirmUpdateExtensionRequest: (_request) => {},
|
||||||
|
setConfirmationRequest: (_request) => {},
|
||||||
removeComponent: () => {},
|
removeComponent: () => {},
|
||||||
toggleBackgroundShell: () => {},
|
toggleBackgroundShell: () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||||||
import * as fs from 'node:fs/promises';
|
import * as fs from 'node:fs/promises';
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
import * as os from 'node:os';
|
import * as os from 'node:os';
|
||||||
import { installSkill } from './skillUtils.js';
|
import { installSkill, linkSkill } from './skillUtils.js';
|
||||||
|
|
||||||
describe('skillUtils', () => {
|
describe('skillUtils', () => {
|
||||||
let tempDir: string;
|
let tempDir: string;
|
||||||
@@ -24,6 +24,94 @@ describe('skillUtils', () => {
|
|||||||
vi.restoreAllMocks();
|
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 () => {
|
it('should successfully install from a .skill file', async () => {
|
||||||
const skillPath = path.join(projectRoot, 'weather-skill.skill');
|
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.
|
* Central logic for uninstalling a skill by name.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -254,4 +254,21 @@ description:no-space-desc
|
|||||||
expect(skills[0].name).toBe('no-space-name');
|
expect(skills[0].name).toBe('no-space-name');
|
||||||
expect(skills[0].description).toBe('no-space-desc');
|
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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -121,10 +121,12 @@ export async function loadSkillsFromDir(
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const skillFiles = await glob(['SKILL.md', '*/SKILL.md'], {
|
const pattern = ['SKILL.md', '*/SKILL.md'];
|
||||||
|
const skillFiles = await glob(pattern, {
|
||||||
cwd: absoluteSearchPath,
|
cwd: absoluteSearchPath,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
nodir: true,
|
nodir: true,
|
||||||
|
ignore: ['**/node_modules/**', '**/.git/**'],
|
||||||
});
|
});
|
||||||
|
|
||||||
for (const skillFile of skillFiles) {
|
for (const skillFile of skillFiles) {
|
||||||
@@ -171,8 +173,11 @@ export async function loadSkillFromFile(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sanitize name for use as a filename/directory name (e.g. replace ':' with '-')
|
||||||
|
const sanitizedName = frontmatter.name.replace(/[:\\/<>*?"|]/g, '-');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: frontmatter.name,
|
name: sanitizedName,
|
||||||
description: frontmatter.description,
|
description: frontmatter.description,
|
||||||
location: filePath,
|
location: filePath,
|
||||||
body: match[2]?.trim() ?? '',
|
body: match[2]?.trim() ?? '',
|
||||||
|
|||||||
Reference in New Issue
Block a user