diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 1802e590cd..831467732a 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -158,6 +158,7 @@ export * from './tools/read-many-files.js'; export * from './tools/mcp-client.js'; export * from './tools/mcp-tool.js'; export * from './tools/write-todos.js'; +export * from './tools/activate-skill.js'; export * from './tools/ask-user.js'; // MCP OAuth diff --git a/packages/core/src/skills/skillManager.ts b/packages/core/src/skills/skillManager.ts index 02e9d72898..108135af30 100644 --- a/packages/core/src/skills/skillManager.ts +++ b/packages/core/src/skills/skillManager.ts @@ -107,6 +107,13 @@ export class SkillManager { this.addSkillsWithPrecedence(builtinSkills); } + /** + * Adds skills to the manager programmatically. + */ + addSkills(skills: SkillDefinition[]): void { + this.addSkillsWithPrecedence(skills); + } + private addSkillsWithPrecedence(newSkills: SkillDefinition[]): void { const skillMap = new Map( this.skills.map((s) => [s.name, s]), diff --git a/packages/sdk/SDK_DESIGN.md b/packages/sdk/SDK_DESIGN.md index de0db24100..d8c8512991 100644 --- a/packages/sdk/SDK_DESIGN.md +++ b/packages/sdk/SDK_DESIGN.md @@ -141,33 +141,34 @@ Validation (these are probably hardest to validate): ## `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" (directories containing many skills). -```ts -import { GeminiCliAgent, skillDir, skillRoot } from '@google/gemini-cli-sdk'; +### Directory Structure -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 -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: +### Usage -```ts -import { GeminiCliAgent, skill } from '@google/gemini-cli-sdk'; +```typescript +import { GeminiCliAgent, skillDir } from '@google/gemini-sdk'; -const mySkill = skill({ - name: 'my-skill', - description: 'description of when my skill should be used', - content: 'This is the SKILL.md content', - // it can also be a function - content: (ctx) => `This is dynamic content.`, +const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + skills: [ + // Load a single skill from a directory + skillDir('./my-skill'), + // 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: -## Feature 2: Custom Skills Support +## Feature 3: Custom Hooks Support -Implement support for loading and registering custom skills. This involves -adding a `skills` option to `GeminiCliAgentOptions` and implementing the logic -to read skill definitions from directories. +Implement support for loading and registering custom hooks. This involves adding +a `hooks` option to `GeminiCliAgentOptions`. **Tasks:** -1. Add `skills` option to `GeminiCliAgentOptions`. -2. Implement `skillDir` and `skillRoot` helpers to load skills from the - filesystem. -3. Update `GeminiCliAgent` to register loaded skills with the internal tool - registry. +1. Define `Hook` interface and helper functions. +2. Add `hooks` option to `GeminiCliAgentOptions`. +3. Implement hook registration logic in `GeminiCliAgent`. + +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. diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts index a63414bddd..7db03a98f5 100644 --- a/packages/sdk/src/agent.ts +++ b/packages/sdk/src/agent.ts @@ -17,12 +17,15 @@ import { scheduleAgentTools, getAuthTypeFromEnv, type ToolRegistry, + loadSkillsFromDir, + ActivateSkillTool, } from '@google/gemini-cli-core'; import { type Tool, SdkTool } from './tool.js'; import { SdkAgentFilesystem } from './fs.js'; import { SdkAgentShell } from './shell.js'; import type { SessionContext } from './types.js'; +import type { SkillReference } from './skills.js'; export type SystemInstructions = | string @@ -32,6 +35,7 @@ export interface GeminiCliAgentOptions { instructions: SystemInstructions; // eslint-disable-next-line @typescript-eslint/no-explicit-any tools?: Array>; + skills?: SkillReference[]; model?: string; cwd?: string; debug?: boolean; @@ -40,9 +44,10 @@ export interface GeminiCliAgentOptions { } export class GeminiCliAgent { - private config: Config; + private readonly config: Config; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private tools: Array>; + private readonly tools: Array>; + private readonly skillRefs: SkillReference[]; private readonly instructions: SystemInstructions; private instructionsLoaded = false; @@ -50,6 +55,7 @@ export class GeminiCliAgent { this.instructions = options.instructions; const cwd = options.cwd || process.cwd(); this.tools = options.tools || []; + this.skillRefs = options.skills || []; const initialMemory = typeof this.instructions === 'string' ? this.instructions : ''; @@ -67,6 +73,8 @@ export class GeminiCliAgent { extensionsEnabled: false, recordResponses: options.recordResponses, fakeResponses: options.fakeResponses, + skillsSupport: true, + adminSkillsEnabled: true, }; this.config = new Config(configParams); @@ -83,6 +91,45 @@ export class GeminiCliAgent { await this.config.refreshAuth(authType); 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 const registry = this.config.getToolRegistry(); const messageBus = this.config.getMessageBus(); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 36a4c7711d..f1b9e020f5 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -6,4 +6,5 @@ export * from './agent.js'; export * from './tool.js'; +export * from './skills.js'; export * from './types.js'; diff --git a/packages/sdk/src/skills.integration.test.ts b/packages/sdk/src/skills.integration.test.ts new file mode 100644 index 0000000000..a91480ff30 --- /dev/null +++ b/packages/sdk/src/skills.integration.test.ts @@ -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); +}); diff --git a/packages/sdk/src/skills.ts b/packages/sdk/src/skills.ts new file mode 100644 index 0000000000..37d58214d1 --- /dev/null +++ b/packages/sdk/src/skills.ts @@ -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 }; +} diff --git a/packages/sdk/test-data/skill-dir-success.json b/packages/sdk/test-data/skill-dir-success.json new file mode 100644 index 0000000000..f14658426f --- /dev/null +++ b/packages/sdk/test-data/skill-dir-success.json @@ -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}]}}]} diff --git a/packages/sdk/test-data/skill-root-success.json b/packages/sdk/test-data/skill-root-success.json new file mode 100644 index 0000000000..1048a2c627 --- /dev/null +++ b/packages/sdk/test-data/skill-root-success.json @@ -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}]}}]} diff --git a/packages/sdk/test-data/skills/pirate-skill/SKILL.md b/packages/sdk/test-data/skills/pirate-skill/SKILL.md new file mode 100644 index 0000000000..6c7100f47e --- /dev/null +++ b/packages/sdk/test-data/skills/pirate-skill/SKILL.md @@ -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". \ No newline at end of file