From ad8796b02db3cbb34c8318b53b7b0fc5113e264b Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Mon, 2 Feb 2026 22:07:36 -0800 Subject: [PATCH] feat(core): add .agents/skills directory alias for skill discovery (#18151) --- packages/core/src/config/storage.test.ts | 26 ++- packages/core/src/config/storage.ts | 21 +++ packages/core/src/skills/skillManager.ts | 12 ++ .../core/src/skills/skillManagerAlias.test.ts | 178 ++++++++++++++++++ 4 files changed, 236 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/skills/skillManagerAlias.test.ts diff --git a/packages/core/src/config/storage.test.ts b/packages/core/src/config/storage.test.ts index a635bcbf14..8d4482c503 100644 --- a/packages/core/src/config/storage.test.ts +++ b/packages/core/src/config/storage.test.ts @@ -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(); + 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); diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index fc5006d04e..c541485d0a 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -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'); } diff --git a/packages/core/src/skills/skillManager.ts b/packages/core/src/skills/skillManager.ts index d80202cd5b..be0d3d81ff 100644 --- a/packages/core/src/skills/skillManager.ts +++ b/packages/core/src/skills/skillManager.ts @@ -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); } /** diff --git a/packages/core/src/skills/skillManagerAlias.test.ts b/packages/core/src/skills/skillManagerAlias.test.ts new file mode 100644 index 0000000000..0764721de9 --- /dev/null +++ b/packages/core/src/skills/skillManagerAlias.test.ts @@ -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(); + 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'); + }); +});