mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-21 10:34:35 -07:00
feat(sdk): implement support for custom skills (#19031)
This commit is contained in:
@@ -158,6 +158,7 @@ export * from './tools/read-many-files.js';
|
|||||||
export * from './tools/mcp-client.js';
|
export * from './tools/mcp-client.js';
|
||||||
export * from './tools/mcp-tool.js';
|
export * from './tools/mcp-tool.js';
|
||||||
export * from './tools/write-todos.js';
|
export * from './tools/write-todos.js';
|
||||||
|
export * from './tools/activate-skill.js';
|
||||||
export * from './tools/ask-user.js';
|
export * from './tools/ask-user.js';
|
||||||
|
|
||||||
// MCP OAuth
|
// MCP OAuth
|
||||||
|
|||||||
@@ -107,6 +107,13 @@ export class SkillManager {
|
|||||||
this.addSkillsWithPrecedence(builtinSkills);
|
this.addSkillsWithPrecedence(builtinSkills);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds skills to the manager programmatically.
|
||||||
|
*/
|
||||||
|
addSkills(skills: SkillDefinition[]): void {
|
||||||
|
this.addSkillsWithPrecedence(skills);
|
||||||
|
}
|
||||||
|
|
||||||
private addSkillsWithPrecedence(newSkills: SkillDefinition[]): void {
|
private addSkillsWithPrecedence(newSkills: SkillDefinition[]): void {
|
||||||
const skillMap = new Map<string, SkillDefinition>(
|
const skillMap = new Map<string, SkillDefinition>(
|
||||||
this.skills.map((s) => [s.name, s]),
|
this.skills.map((s) => [s.name, s]),
|
||||||
|
|||||||
+28
-27
@@ -141,33 +141,34 @@ Validation (these are probably hardest to validate):
|
|||||||
|
|
||||||
## `Custom Skills`
|
## `Custom Skills`
|
||||||
|
|
||||||
> **Status:** Not Implemented.
|
> **Status:** Implemented. `skillDir` helper and `GeminiCliAgent` support
|
||||||
|
> loading skills from filesystem.
|
||||||
|
|
||||||
Custom skills can be referenced by individual directories or by "skill roots"
|
Custom skills can be referenced by individual directories or by "skill roots"
|
||||||
(directories containing many skills).
|
(directories containing many skills).
|
||||||
|
|
||||||
```ts
|
### Directory Structure
|
||||||
import { GeminiCliAgent, skillDir, skillRoot } from '@google/gemini-cli-sdk';
|
|
||||||
|
|
||||||
const agent = new GeminiCliAgent({
|
```
|
||||||
skills: [skillDir('/path/to/single/skill'), skillRoot('/path/to/skills/dir')],
|
skill-dir/
|
||||||
});
|
SKILL.md (Metadata and instructions)
|
||||||
|
tools/ (Optional directory for tools)
|
||||||
|
my-tool.js
|
||||||
```
|
```
|
||||||
|
|
||||||
**NOTE:** I would like to support fully in-memory skills (including reference
|
### Usage
|
||||||
files); however, it seems like that would currently require a pretty significant
|
|
||||||
refactor so we'll focus on filesystem skills for now. In an ideal future state,
|
|
||||||
we could do something like:
|
|
||||||
|
|
||||||
```ts
|
```typescript
|
||||||
import { GeminiCliAgent, skill } from '@google/gemini-cli-sdk';
|
import { GeminiCliAgent, skillDir } from '@google/gemini-sdk';
|
||||||
|
|
||||||
const mySkill = skill({
|
const agent = new GeminiCliAgent({
|
||||||
name: 'my-skill',
|
instructions: 'You are a helpful assistant.',
|
||||||
description: 'description of when my skill should be used',
|
skills: [
|
||||||
content: 'This is the SKILL.md content',
|
// Load a single skill from a directory
|
||||||
// it can also be a function
|
skillDir('./my-skill'),
|
||||||
content: (ctx) => `This is dynamic content.`,
|
// Load all skills found in subdirectories of this root
|
||||||
|
skillDir('./skills-collection'),
|
||||||
|
],
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -318,16 +319,16 @@ export interface AgentShellProcess {
|
|||||||
|
|
||||||
Based on the current implementation status, we can proceed with:
|
Based on the current implementation status, we can proceed with:
|
||||||
|
|
||||||
## Feature 2: Custom Skills Support
|
## Feature 3: Custom Hooks Support
|
||||||
|
|
||||||
Implement support for loading and registering custom skills. This involves
|
Implement support for loading and registering custom hooks. This involves adding
|
||||||
adding a `skills` option to `GeminiCliAgentOptions` and implementing the logic
|
a `hooks` option to `GeminiCliAgentOptions`.
|
||||||
to read skill definitions from directories.
|
|
||||||
|
|
||||||
**Tasks:**
|
**Tasks:**
|
||||||
|
|
||||||
1. Add `skills` option to `GeminiCliAgentOptions`.
|
1. Define `Hook` interface and helper functions.
|
||||||
2. Implement `skillDir` and `skillRoot` helpers to load skills from the
|
2. Add `hooks` option to `GeminiCliAgentOptions`.
|
||||||
filesystem.
|
3. Implement hook registration logic in `GeminiCliAgent`.
|
||||||
3. Update `GeminiCliAgent` to register loaded skills with the internal tool
|
|
||||||
registry.
|
IMPORTANT: Hook signatures should be strongly typed all the way through. You'll
|
||||||
|
need to create a mapping of the string event name to the request/response types.
|
||||||
|
|||||||
@@ -17,12 +17,15 @@ import {
|
|||||||
scheduleAgentTools,
|
scheduleAgentTools,
|
||||||
getAuthTypeFromEnv,
|
getAuthTypeFromEnv,
|
||||||
type ToolRegistry,
|
type ToolRegistry,
|
||||||
|
loadSkillsFromDir,
|
||||||
|
ActivateSkillTool,
|
||||||
} from '@google/gemini-cli-core';
|
} from '@google/gemini-cli-core';
|
||||||
|
|
||||||
import { type Tool, SdkTool } from './tool.js';
|
import { type Tool, SdkTool } from './tool.js';
|
||||||
import { SdkAgentFilesystem } from './fs.js';
|
import { SdkAgentFilesystem } from './fs.js';
|
||||||
import { SdkAgentShell } from './shell.js';
|
import { SdkAgentShell } from './shell.js';
|
||||||
import type { SessionContext } from './types.js';
|
import type { SessionContext } from './types.js';
|
||||||
|
import type { SkillReference } from './skills.js';
|
||||||
|
|
||||||
export type SystemInstructions =
|
export type SystemInstructions =
|
||||||
| string
|
| string
|
||||||
@@ -32,6 +35,7 @@ export interface GeminiCliAgentOptions {
|
|||||||
instructions: SystemInstructions;
|
instructions: SystemInstructions;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
tools?: Array<Tool<any>>;
|
tools?: Array<Tool<any>>;
|
||||||
|
skills?: SkillReference[];
|
||||||
model?: string;
|
model?: string;
|
||||||
cwd?: string;
|
cwd?: string;
|
||||||
debug?: boolean;
|
debug?: boolean;
|
||||||
@@ -40,9 +44,10 @@ export interface GeminiCliAgentOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class GeminiCliAgent {
|
export class GeminiCliAgent {
|
||||||
private config: Config;
|
private readonly config: Config;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
private tools: Array<Tool<any>>;
|
private readonly tools: Array<Tool<any>>;
|
||||||
|
private readonly skillRefs: SkillReference[];
|
||||||
private readonly instructions: SystemInstructions;
|
private readonly instructions: SystemInstructions;
|
||||||
private instructionsLoaded = false;
|
private instructionsLoaded = false;
|
||||||
|
|
||||||
@@ -50,6 +55,7 @@ export class GeminiCliAgent {
|
|||||||
this.instructions = options.instructions;
|
this.instructions = options.instructions;
|
||||||
const cwd = options.cwd || process.cwd();
|
const cwd = options.cwd || process.cwd();
|
||||||
this.tools = options.tools || [];
|
this.tools = options.tools || [];
|
||||||
|
this.skillRefs = options.skills || [];
|
||||||
|
|
||||||
const initialMemory =
|
const initialMemory =
|
||||||
typeof this.instructions === 'string' ? this.instructions : '';
|
typeof this.instructions === 'string' ? this.instructions : '';
|
||||||
@@ -67,6 +73,8 @@ export class GeminiCliAgent {
|
|||||||
extensionsEnabled: false,
|
extensionsEnabled: false,
|
||||||
recordResponses: options.recordResponses,
|
recordResponses: options.recordResponses,
|
||||||
fakeResponses: options.fakeResponses,
|
fakeResponses: options.fakeResponses,
|
||||||
|
skillsSupport: true,
|
||||||
|
adminSkillsEnabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.config = new Config(configParams);
|
this.config = new Config(configParams);
|
||||||
@@ -83,6 +91,45 @@ export class GeminiCliAgent {
|
|||||||
await this.config.refreshAuth(authType);
|
await this.config.refreshAuth(authType);
|
||||||
await this.config.initialize();
|
await this.config.initialize();
|
||||||
|
|
||||||
|
// Load additional skills from options
|
||||||
|
if (this.skillRefs.length > 0) {
|
||||||
|
const skillManager = this.config.getSkillManager();
|
||||||
|
|
||||||
|
const loadPromises = this.skillRefs.map(async (ref) => {
|
||||||
|
try {
|
||||||
|
if (ref.type === 'dir') {
|
||||||
|
return await loadSkillsFromDir(ref.path);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(`Failed to load skills from ${ref.path}:`, e);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadedSkills = (await Promise.all(loadPromises)).flat();
|
||||||
|
|
||||||
|
if (loadedSkills.length > 0) {
|
||||||
|
skillManager.addSkills(loadedSkills);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-register ActivateSkillTool if we have skills (either built-in/workspace or manually loaded)
|
||||||
|
// This is required because ActivateSkillTool captures the set of available skills at construction time.
|
||||||
|
const skillManager = this.config.getSkillManager();
|
||||||
|
if (skillManager.getSkills().length > 0) {
|
||||||
|
const registry = this.config.getToolRegistry();
|
||||||
|
const toolName = ActivateSkillTool.Name;
|
||||||
|
// Config.initialize already registers it, but we might have added more skills.
|
||||||
|
// Re-registering updates the schema with new skills.
|
||||||
|
if (registry.getTool(toolName)) {
|
||||||
|
registry.unregisterTool(toolName);
|
||||||
|
}
|
||||||
|
registry.registerTool(
|
||||||
|
new ActivateSkillTool(this.config, this.config.getMessageBus()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Register tools now that registry exists
|
// Register tools now that registry exists
|
||||||
const registry = this.config.getToolRegistry();
|
const registry = this.config.getToolRegistry();
|
||||||
const messageBus = this.config.getMessageBus();
|
const messageBus = this.config.getMessageBus();
|
||||||
|
|||||||
@@ -6,4 +6,5 @@
|
|||||||
|
|
||||||
export * from './agent.js';
|
export * from './agent.js';
|
||||||
export * from './tool.js';
|
export * from './tool.js';
|
||||||
|
export * from './skills.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { GeminiCliAgent } from './agent.js';
|
||||||
|
import { skillDir } from './skills.js';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname } from 'node:path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Set this to true locally when you need to update snapshots
|
||||||
|
const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true';
|
||||||
|
|
||||||
|
const getGoldenPath = (name: string) =>
|
||||||
|
path.resolve(__dirname, '../test-data', `${name}.json`);
|
||||||
|
|
||||||
|
const SKILL_DIR = path.resolve(__dirname, '../test-data/skills/pirate-skill');
|
||||||
|
const SKILL_ROOT = path.resolve(__dirname, '../test-data/skills');
|
||||||
|
|
||||||
|
describe('GeminiCliAgent Skills Integration', () => {
|
||||||
|
it('loads and activates a skill from a directory', async () => {
|
||||||
|
const goldenFile = getGoldenPath('skill-dir-success');
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
skills: [skillDir(SKILL_DIR)],
|
||||||
|
// If recording, use real model + record path.
|
||||||
|
// If testing, use auto model + fake path.
|
||||||
|
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||||
|
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||||
|
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Ask to activate the skill
|
||||||
|
const events = [];
|
||||||
|
// The prompt explicitly asks to activate the skill by name
|
||||||
|
const stream = agent.sendStream(
|
||||||
|
'Activate the pirate-skill and then tell me a joke.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEvents = events.filter((e) => e.type === 'content');
|
||||||
|
const responseText = textEvents
|
||||||
|
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Expect pirate speak
|
||||||
|
expect(responseText.toLowerCase()).toContain('arrr');
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
it('loads and activates a skill from a root', async () => {
|
||||||
|
const goldenFile = getGoldenPath('skill-root-success');
|
||||||
|
|
||||||
|
const agent = new GeminiCliAgent({
|
||||||
|
instructions: 'You are a helpful assistant.',
|
||||||
|
skills: [skillDir(SKILL_ROOT)],
|
||||||
|
// If recording, use real model + record path.
|
||||||
|
// If testing, use auto model + fake path.
|
||||||
|
model: RECORD_MODE ? 'gemini-2.0-flash' : undefined,
|
||||||
|
recordResponses: RECORD_MODE ? goldenFile : undefined,
|
||||||
|
fakeResponses: RECORD_MODE ? undefined : goldenFile,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Ask to activate the skill
|
||||||
|
const events = [];
|
||||||
|
const stream = agent.sendStream(
|
||||||
|
'Activate the pirate-skill and confirm it is active.',
|
||||||
|
);
|
||||||
|
|
||||||
|
for await (const event of stream) {
|
||||||
|
events.push(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
const textEvents = events.filter((e) => e.type === 'content');
|
||||||
|
const responseText = textEvents
|
||||||
|
.map((e) => (typeof e.value === 'string' ? e.value : ''))
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
// Expect confirmation or pirate speak
|
||||||
|
expect(responseText.toLowerCase()).toContain('arrr');
|
||||||
|
}, 60000);
|
||||||
|
});
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type SkillReference = { type: 'dir'; path: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reference a directory containing skills.
|
||||||
|
*
|
||||||
|
* @param path Path to the skill directory
|
||||||
|
*/
|
||||||
|
export function skillDir(path: string): SkillReference {
|
||||||
|
return { type: 'dir', path };
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7160,"candidatesTokenCount":8,"totalTokenCount":7168,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7160}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play poker? Because they always have a hidden ace up their sleeves"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":"!\\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7284,"candidatesTokenCount":23,"totalTokenCount":7307,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7284}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7170,"candidatesTokenCount":8,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7170}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]}
|
||||||
|
{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10058,"totalTokenCount":10058,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10058}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate skill be active, matey!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7294,"candidatesTokenCount":12,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7294}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
name: pirate-skill
|
||||||
|
description: Speak like a pirate.
|
||||||
|
---
|
||||||
|
You are a pirate. Respond to everything in pirate speak. Always mention "Arrr".
|
||||||
Reference in New Issue
Block a user