mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
feat(core): add .agents/skills directory alias for skill discovery (#18151)
This commit is contained in:
@@ -17,7 +17,15 @@ vi.mock('fs', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { Storage } from './storage.js';
|
||||
import { GEMINI_DIR } from '../utils/paths.js';
|
||||
import { GEMINI_DIR, homedir } from '../utils/paths.js';
|
||||
|
||||
vi.mock('../utils/paths.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../utils/paths.js')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: vi.fn(actual.homedir),
|
||||
};
|
||||
});
|
||||
|
||||
describe('Storage – getGlobalSettingsPath', () => {
|
||||
it('returns path to ~/.gemini/settings.json', () => {
|
||||
@@ -26,6 +34,22 @@ describe('Storage – getGlobalSettingsPath', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage - Security', () => {
|
||||
it('falls back to tmp for gemini but returns empty for agents if the home directory cannot be determined', () => {
|
||||
vi.mocked(homedir).mockReturnValue('');
|
||||
|
||||
// .gemini falls back for backward compatibility
|
||||
expect(Storage.getGlobalGeminiDir()).toBe(
|
||||
path.join(os.tmpdir(), GEMINI_DIR),
|
||||
);
|
||||
|
||||
// .agents returns empty to avoid insecure fallback WITHOUT throwing error
|
||||
expect(Storage.getGlobalAgentsDir()).toBe('');
|
||||
|
||||
vi.mocked(homedir).mockReturnValue(os.homedir());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Storage – additional helpers', () => {
|
||||
const projectRoot = '/tmp/project';
|
||||
const storage = new Storage(projectRoot);
|
||||
|
||||
@@ -14,6 +14,7 @@ export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json';
|
||||
export const OAUTH_FILE = 'oauth_creds.json';
|
||||
const TMP_DIR_NAME = 'tmp';
|
||||
const BIN_DIR_NAME = 'bin';
|
||||
const AGENTS_DIR_NAME = '.agents';
|
||||
|
||||
export class Storage {
|
||||
private readonly targetDir: string;
|
||||
@@ -30,6 +31,14 @@ export class Storage {
|
||||
return path.join(homeDir, GEMINI_DIR);
|
||||
}
|
||||
|
||||
static getGlobalAgentsDir(): string {
|
||||
const homeDir = homedir();
|
||||
if (!homeDir) {
|
||||
return '';
|
||||
}
|
||||
return path.join(homeDir, AGENTS_DIR_NAME);
|
||||
}
|
||||
|
||||
static getMcpOAuthTokensPath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'mcp-oauth-tokens.json');
|
||||
}
|
||||
@@ -54,6 +63,10 @@ export class Storage {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'skills');
|
||||
}
|
||||
|
||||
static getUserAgentSkillsDir(): string {
|
||||
return path.join(Storage.getGlobalAgentsDir(), 'skills');
|
||||
}
|
||||
|
||||
static getGlobalMemoryFilePath(): string {
|
||||
return path.join(Storage.getGlobalGeminiDir(), 'memory.md');
|
||||
}
|
||||
@@ -107,6 +120,10 @@ export class Storage {
|
||||
return path.join(this.targetDir, GEMINI_DIR);
|
||||
}
|
||||
|
||||
getAgentsDir(): string {
|
||||
return path.join(this.targetDir, AGENTS_DIR_NAME);
|
||||
}
|
||||
|
||||
getProjectTempDir(): string {
|
||||
const hash = this.getFilePathHash(this.getProjectRoot());
|
||||
const tempDir = Storage.getGlobalTempDir();
|
||||
@@ -147,6 +164,10 @@ export class Storage {
|
||||
return path.join(this.getGeminiDir(), 'skills');
|
||||
}
|
||||
|
||||
getProjectAgentSkillsDir(): string {
|
||||
return path.join(this.getAgentsDir(), 'skills');
|
||||
}
|
||||
|
||||
getProjectAgentsDir(): string {
|
||||
return path.join(this.getGeminiDir(), 'agents');
|
||||
}
|
||||
|
||||
@@ -64,11 +64,23 @@ export class SkillManager {
|
||||
const userSkills = await loadSkillsFromDir(Storage.getUserSkillsDir());
|
||||
this.addSkillsWithPrecedence(userSkills);
|
||||
|
||||
// 3.1 User agent skills alias (.agents/skills)
|
||||
const userAgentSkills = await loadSkillsFromDir(
|
||||
Storage.getUserAgentSkillsDir(),
|
||||
);
|
||||
this.addSkillsWithPrecedence(userAgentSkills);
|
||||
|
||||
// 4. Workspace skills (highest precedence)
|
||||
const projectSkills = await loadSkillsFromDir(
|
||||
storage.getProjectSkillsDir(),
|
||||
);
|
||||
this.addSkillsWithPrecedence(projectSkills);
|
||||
|
||||
// 4.1 Workspace agent skills alias (.agents/skills)
|
||||
const projectAgentSkills = await loadSkillsFromDir(
|
||||
storage.getProjectAgentSkillsDir(),
|
||||
);
|
||||
this.addSkillsWithPrecedence(projectAgentSkills);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
178
packages/core/src/skills/skillManagerAlias.test.ts
Normal file
178
packages/core/src/skills/skillManagerAlias.test.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { SkillManager } from './skillManager.js';
|
||||
import { Storage } from '../config/storage.js';
|
||||
import { loadSkillsFromDir } from './skillLoader.js';
|
||||
|
||||
vi.mock('./skillLoader.js', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./skillLoader.js')>();
|
||||
return {
|
||||
...actual,
|
||||
loadSkillsFromDir: vi.fn(actual.loadSkillsFromDir),
|
||||
};
|
||||
});
|
||||
|
||||
describe('SkillManager Alias', () => {
|
||||
let testRootDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
testRootDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), 'skill-manager-alias-test-'),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(testRootDir, { recursive: true, force: true });
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should discover skills from .agents/skills directory', async () => {
|
||||
const userGeminiDir = path.join(testRootDir, 'user', '.gemini', 'skills');
|
||||
const userAgentDir = path.join(testRootDir, 'user', '.agents', 'skills');
|
||||
const projectGeminiDir = path.join(
|
||||
testRootDir,
|
||||
'workspace',
|
||||
'.gemini',
|
||||
'skills',
|
||||
);
|
||||
const projectAgentDir = path.join(
|
||||
testRootDir,
|
||||
'workspace',
|
||||
'.agents',
|
||||
'skills',
|
||||
);
|
||||
|
||||
await fs.mkdir(userGeminiDir, { recursive: true });
|
||||
await fs.mkdir(userAgentDir, { recursive: true });
|
||||
await fs.mkdir(projectGeminiDir, { recursive: true });
|
||||
await fs.mkdir(projectAgentDir, { recursive: true });
|
||||
|
||||
vi.mocked(loadSkillsFromDir).mockImplementation(async (dir) => {
|
||||
if (dir === userGeminiDir) {
|
||||
return [
|
||||
{
|
||||
name: 'user-gemini',
|
||||
description: 'desc',
|
||||
location: 'loc',
|
||||
body: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (dir === userAgentDir) {
|
||||
return [
|
||||
{
|
||||
name: 'user-agent',
|
||||
description: 'desc',
|
||||
location: 'loc',
|
||||
body: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (dir === projectGeminiDir) {
|
||||
return [
|
||||
{
|
||||
name: 'project-gemini',
|
||||
description: 'desc',
|
||||
location: 'loc',
|
||||
body: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (dir === projectAgentDir) {
|
||||
return [
|
||||
{
|
||||
name: 'project-agent',
|
||||
description: 'desc',
|
||||
location: 'loc',
|
||||
body: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userGeminiDir);
|
||||
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(userAgentDir);
|
||||
|
||||
const storage = new Storage(path.join(testRootDir, 'workspace'));
|
||||
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(projectGeminiDir);
|
||||
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
|
||||
projectAgentDir,
|
||||
);
|
||||
|
||||
const service = new SkillManager();
|
||||
// @ts-expect-error accessing private method for testing
|
||||
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
|
||||
|
||||
await service.discoverSkills(storage, []);
|
||||
|
||||
const skills = service.getSkills();
|
||||
expect(skills).toHaveLength(4);
|
||||
const names = skills.map((s) => s.name);
|
||||
expect(names).toContain('user-gemini');
|
||||
expect(names).toContain('user-agent');
|
||||
expect(names).toContain('project-gemini');
|
||||
expect(names).toContain('project-agent');
|
||||
});
|
||||
|
||||
it('should give .agents precedence over .gemini when in the same tier', async () => {
|
||||
const userGeminiDir = path.join(testRootDir, 'user', '.gemini', 'skills');
|
||||
const userAgentDir = path.join(testRootDir, 'user', '.agents', 'skills');
|
||||
|
||||
await fs.mkdir(userGeminiDir, { recursive: true });
|
||||
await fs.mkdir(userAgentDir, { recursive: true });
|
||||
|
||||
vi.mocked(loadSkillsFromDir).mockImplementation(async (dir) => {
|
||||
if (dir === userGeminiDir) {
|
||||
return [
|
||||
{
|
||||
name: 'same-skill',
|
||||
description: 'gemini-desc',
|
||||
location: 'loc-gemini',
|
||||
body: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (dir === userAgentDir) {
|
||||
return [
|
||||
{
|
||||
name: 'same-skill',
|
||||
description: 'agent-desc',
|
||||
location: 'loc-agent',
|
||||
body: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
vi.spyOn(Storage, 'getUserSkillsDir').mockReturnValue(userGeminiDir);
|
||||
vi.spyOn(Storage, 'getUserAgentSkillsDir').mockReturnValue(userAgentDir);
|
||||
|
||||
const storage = new Storage('/dummy');
|
||||
vi.spyOn(storage, 'getProjectSkillsDir').mockReturnValue(
|
||||
'/non-existent-gemini',
|
||||
);
|
||||
vi.spyOn(storage, 'getProjectAgentSkillsDir').mockReturnValue(
|
||||
'/non-existent-agent',
|
||||
);
|
||||
|
||||
const service = new SkillManager();
|
||||
// @ts-expect-error accessing private method for testing
|
||||
vi.spyOn(service, 'discoverBuiltinSkills').mockResolvedValue(undefined);
|
||||
|
||||
await service.discoverSkills(storage, []);
|
||||
|
||||
const skills = service.getSkills();
|
||||
expect(skills).toHaveLength(1);
|
||||
expect(skills[0].description).toBe('agent-desc');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user