Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/skills/skillManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, SkillDefinition>(
this.skills.map((s) => [s.name, s]),
Expand Down
57 changes: 29 additions & 28 deletions packages/sdk/SDK_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,33 +141,34 @@ Validation (these are probably hardest to validate):

## `Custom Skills`

> **Status:** Not Implemented.
> **Status:** Implemented. `skillDir`, `skillRoot` helpers 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';

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.`,
```typescript
import { GeminiCliAgent, skillDir, skillRoot } from '@google/gemini-sdk';

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
skillRoot('./skills-collection'),
],
});
```

Expand Down Expand Up @@ -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.
47 changes: 47 additions & 0 deletions packages/sdk/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +35,7 @@ export interface GeminiCliAgentOptions {
instructions: SystemInstructions;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
tools?: Array<Tool<any>>;
skills?: SkillReference[];
model?: string;
cwd?: string;
debug?: boolean;
Expand All @@ -43,13 +47,15 @@ export class GeminiCliAgent {
private config: Config;
Comment thread
mbleigh marked this conversation as resolved.
Resolved
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private tools: Array<Tool<any>>;
private skillRefs: SkillReference[];
private readonly instructions: SystemInstructions;
private instructionsLoaded = false;

constructor(options: GeminiCliAgentOptions) {
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 : '';
Expand All @@ -67,6 +73,8 @@ export class GeminiCliAgent {
extensionsEnabled: false,
recordResponses: options.recordResponses,
fakeResponses: options.fakeResponses,
skillsSupport: true,
adminSkillsEnabled: true,
};

this.config = new Config(configParams);
Expand All @@ -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' || ref.type === 'root') {
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);
}
}
Comment thread
mbleigh marked this conversation as resolved.
Resolved

// 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();
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@

export * from './agent.js';
export * from './tool.js';
export * from './skills.js';
export * from './types.js';
91 changes: 91 additions & 0 deletions packages/sdk/src/skills.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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, skillRoot } 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: [skillRoot(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);
});
29 changes: 29 additions & 0 deletions packages/sdk/src/skills.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

export type SkillReference =
| { type: 'dir'; path: string }
Comment thread
mbleigh marked this conversation as resolved.
Resolved
| { type: 'root'; path: string };

/**
* Reference a directory containing a single skill.
* The directory must contain a SKILL.md file.
*
* @param path Path to the skill directory
*/
export function skillDir(path: string): SkillReference {
return { type: 'dir', path };
}

/**
* Reference a root directory containing multiple skills.
* The directory should contain subdirectories, each with a SKILL.md file.
*
* @param path Path to the skills root directory
*/
export function skillRoot(path: string): SkillReference {
return { type: 'root', path };
}
2 changes: 2 additions & 0 deletions packages/sdk/test-data/skill-dir-success.json
Original file line number Diff line number Diff line change
@@ -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}]}}]}
2 changes: 2 additions & 0 deletions packages/sdk/test-data/skill-root-success.json
Original file line number Diff line number Diff line change
@@ -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}]}}]}
5 changes: 5 additions & 0 deletions packages/sdk/test-data/skills/pirate-skill/SKILL.md
Original file line number Diff line number Diff line change
@@ -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".
Loading