From 5914618cce49949f19f90e1438f727b6c77ec13c Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Tue, 6 Jan 2026 17:16:33 -0800 Subject: [PATCH 1/4] location --- .../contrib/chat/browser/chat.contribution.ts | 15 ++- .../chat/common/promptSyntax/config/config.ts | 31 +++++ .../promptSyntax/utils/promptFilesLocator.ts | 75 +++++++++++ .../common/promptSyntax/config/config.test.ts | 119 +++++++++++++++++- 4 files changed, 238 insertions(+), 2 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index fac472cb5be90..6c8d42fe4b46f 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -707,12 +707,25 @@ configurationRegistry.registerConfiguration({ [PromptsConfig.USE_AGENT_SKILLS]: { type: 'boolean', title: nls.localize('chat.useAgentSkills.title', "Use Agent skills",), - markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from `.github/skills`, `~/.copilot/skills`, `.claude/skills`, and `~/.claude/skills`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), + markdownDescription: nls.localize('chat.useAgentSkills.description', "Controls whether skills are provided as specialized capabilities to the chat requests. Skills are loaded from `.github/skills`, `~/.copilot/skills`, `.claude/skills`, `~/.claude/skills`, and any additional folders configured in `#chat.agentSkillsLocations#`. The language model can load these skills on-demand if the `read` tool is available. Learn more about [Agent Skills](https://aka.ms/vscode-agent-skills).",), default: false, restricted: true, disallowConfigurationDefault: true, tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'] }, + [PromptsConfig.AGENT_SKILLS_LOCATIONS_KEY]: { + type: 'array', + items: { type: 'string' }, + title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), + markdownDescription: nls.localize('chat.agentSkillsLocations.description', "Specify additional folder locations to load agent skills from. Each folder should contain subdirectories with `SKILL.md` files. Relative paths are resolved from the root folder(s) of your workspace. Absolute paths can also be used.",), + default: [], + restricted: true, + tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], + examples: [ + ['/Users/vscode/my-skills'], + ['my-project-skills', '/shared/team-skills'], + ], + }, [PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: { type: 'object', scope: ConfigurationScope.RESOURCE, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index 1ce37155cb463..ffbf8497c0e70 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -83,6 +83,11 @@ export namespace PromptsConfig { */ export const USE_AGENT_SKILLS = 'chat.useAgentSkills'; + /** + * Configuration key for the locations of additional agent skills folders. + */ + export const AGENT_SKILLS_LOCATIONS_KEY = 'chat.agentSkillsLocations'; + /** * Get value of the `reusable prompt locations` configuration setting. * @see {@link PROMPT_LOCATIONS_CONFIG_KEY}, {@link INSTRUCTIONS_LOCATIONS_CONFIG_KEY}, {@link MODE_LOCATIONS_CONFIG_KEY}. @@ -201,6 +206,32 @@ export namespace PromptsConfig { return Object.keys(suggestions).length > 0 ? suggestions : undefined; } + /** + * Get list of additional agent skills folder locations from configuration. + * Returns an array of folder paths where skills should be loaded from. + * @param configService Configuration service instance + * @see {@link AGENT_SKILLS_LOCATIONS_KEY}. + */ + export function getAgentSkillsLocations(configService: IConfigurationService): string[] { + const configValue = configService.getValue(AGENT_SKILLS_LOCATIONS_KEY); + + if (!Array.isArray(configValue)) { + return []; + } + + const result: string[] = []; + for (const path of configValue) { + if (typeof path === 'string') { + const cleanPath = path.trim(); + if (cleanPath) { + result.push(cleanPath); + } + } + } + + return result; + } + } export function getPromptFileLocationsConfigKey(type: PromptsType): string { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index b7a2dd243650b..a38d08c01ca38 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -404,9 +404,84 @@ export class PromptFilesLocator { allResults.push(...results.map(uri => ({ uri, type }))); } } + + // Also search in additional configured skill folders + const additionalFolders = await this.findAgentSkillsInConfiguredLocations(token); + allResults.push(...additionalFolders); + + return allResults; + } + + /** + * Searches for skills in additional folders configured via the `chat.agentSkillsLocations` setting. + * Each skill is stored in its own subdirectory with a SKILL.md file. + */ + private async findAgentSkillsInConfiguredLocations(token: CancellationToken): Promise> { + const configuredLocations = PromptsConfig.getAgentSkillsLocations(this.configService); + if (configuredLocations.length === 0) { + return []; + } + + const allResults: Array<{ uri: URI; type: string }> = []; + const { folders } = this.workspaceService.getWorkspace(); + + for (const location of configuredLocations) { + try { + let uris: URI[]; + if (isAbsolute(location)) { + let uri = URI.file(location); + const remoteAuthority = this.environmentService.remoteAuthority; + if (remoteAuthority) { + // If the location is absolute and we are in a remote environment, + // we need to convert it to a file URI with the remote authority + uri = uri.with({ scheme: Schemas.vscodeRemote, authority: remoteAuthority }); + } + uris = [uri]; + } else { + // Relative paths are resolved from workspace folders + uris = folders.map(folder => joinPath(folder.uri, location)); + } + + for (const uri of uris) { + const results = await this.findAgentSkillsInFolderDirect(uri, token); + allResults.push(...results.map(skillUri => ({ uri: skillUri, type: 'custom' }))); + } + } catch (error) { + this.logService.error(`Failed to resolve agent skills location: ${location}`, error); + } + } + return allResults; } + /** + * Finds skills directly in a folder (the folder itself contains skill subdirectories). + */ + private async findAgentSkillsInFolderDirect(folderUri: URI, token: CancellationToken): Promise { + const result: URI[] = []; + try { + const stat = await this.fileService.resolve(folderUri); + if (token.isCancellationRequested) { + return []; + } + if (stat.isDirectory && stat.children) { + for (const skillDir of stat.children) { + if (skillDir.isDirectory) { + const skillFile = joinPath(skillDir.resource, 'SKILL.md'); + if (await this.fileService.exists(skillFile)) { + result.push(skillFile); + } + } + } + } + } catch (error) { + // No such folder, return empty list + return []; + } + + return result; + } + /** * Searches for skills in all default directories in the home folder. * Each skill is stored in its own subdirectory with a SKILL.md file. diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts index 98302f3211ce9..cc24e88824c33 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts @@ -22,7 +22,7 @@ function createMock(value: T): IConfigurationService { ); assert( - [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY].includes(key), + [PromptsConfig.PROMPT_LOCATIONS_KEY, PromptsConfig.INSTRUCTIONS_LOCATION_KEY, PromptsConfig.MODE_LOCATION_KEY, PromptsConfig.AGENT_SKILLS_LOCATIONS_KEY].includes(key), `Unsupported configuration key '${key}'.`, ); @@ -332,4 +332,121 @@ suite('PromptsConfig', () => { }); }); }); + + suite('getAgentSkillsLocations', () => { + test('undefined returns empty array', () => { + const configService = createMock(undefined); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [], + 'Must return empty array for undefined value.', + ); + }); + + test('null returns empty array', () => { + const configService = createMock(null); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [], + 'Must return empty array for null value.', + ); + }); + + test('non-array returns empty array', () => { + const configService = createMock({ path: '/some/path' }); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [], + 'Must return empty array for non-array value.', + ); + }); + + test('empty array returns empty array', () => { + const configService = createMock([]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [], + 'Must return empty array for empty array value.', + ); + }); + + test('valid paths are returned', () => { + const configService = createMock([ + '/absolute/path/to/skills', + 'relative/path/to/skills', + './another/relative/path', + ]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [ + '/absolute/path/to/skills', + 'relative/path/to/skills', + './another/relative/path', + ], + 'Must return all valid paths.', + ); + }); + + test('filters out empty and whitespace-only paths', () => { + const configService = createMock([ + '/valid/path', + '', + ' ', + '\t\n', + 'another/valid/path', + ]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [ + '/valid/path', + 'another/valid/path', + ], + 'Must filter out empty and whitespace-only paths.', + ); + }); + + test('filters out non-string values', () => { + const configService = createMock([ + '/valid/path', + 123, + null, + undefined, + { path: 'object' }, + 'another/valid/path', + ]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [ + '/valid/path', + 'another/valid/path', + ], + 'Must filter out non-string values.', + ); + }); + + test('trims whitespace from paths', () => { + const configService = createMock([ + ' /path/with/leading/space', + '/path/with/trailing/space ', + ' /path/with/both ', + ]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService), + [ + '/path/with/leading/space', + '/path/with/trailing/space', + '/path/with/both', + ], + 'Must trim whitespace from paths.', + ); + }); + }); }); From ae38adda132a7002510d47b9b48f0b67d15383e1 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Thu, 8 Jan 2026 12:47:33 -0800 Subject: [PATCH 2/4] small tweak --- .../common/promptSyntax/service/promptsServiceImpl.ts | 3 +++ .../chat/common/promptSyntax/utils/promptFilesLocator.ts | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index 8067559def970..a5a5805870dd6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -672,6 +672,7 @@ export class PromptsService extends Disposable implements IPromptsService { githubWorkspace: number; customPersonal: number; customWorkspace: number; + customOther: number; skippedDuplicateName: number; skippedMissingName: number; skippedParseFailed: number; @@ -685,6 +686,7 @@ export class PromptsService extends Disposable implements IPromptsService { githubWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of GitHub workspace skills.' }; customPersonal: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom personal skills.' }; customWorkspace: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom workspace skills.' }; + customOther: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of custom other skills.' }; skippedDuplicateName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to duplicate names.' }; skippedMissingName: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to missing name attribute.' }; skippedParseFailed: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; isMeasurement: true; comment: 'Number of skills skipped due to parse failures.' }; @@ -700,6 +702,7 @@ export class PromptsService extends Disposable implements IPromptsService { githubWorkspace: skillTypes.get('github-workspace') ?? 0, customPersonal: skillTypes.get('custom-personal') ?? 0, customWorkspace: skillTypes.get('custom-workspace') ?? 0, + customOther: skillTypes.get('custom-other') ?? 0, skippedDuplicateName, skippedMissingName, skippedParseFailed diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index a38d08c01ca38..5f0309357e3df 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -424,10 +424,13 @@ export class PromptFilesLocator { const allResults: Array<{ uri: URI; type: string }> = []; const { folders } = this.workspaceService.getWorkspace(); + const userHome = await this.pathService.userHome(); for (const location of configuredLocations) { try { let uris: URI[]; + let skillType: string; + if (isAbsolute(location)) { let uri = URI.file(location); const remoteAuthority = this.environmentService.remoteAuthority; @@ -437,14 +440,18 @@ export class PromptFilesLocator { uri = uri.with({ scheme: Schemas.vscodeRemote, authority: remoteAuthority }); } uris = [uri]; + // Absolute path starting with home is personal, otherwise other + skillType = location.startsWith(userHome.fsPath) ? 'custom-personal' : 'custom-other'; } else { // Relative paths are resolved from workspace folders uris = folders.map(folder => joinPath(folder.uri, location)); + // Relative path without '..' is workspace, otherwise other + skillType = location.startsWith('..') ? 'custom-other' : 'custom-workspace'; } for (const uri of uris) { const results = await this.findAgentSkillsInFolderDirect(uri, token); - allResults.push(...results.map(skillUri => ({ uri: skillUri, type: 'custom' }))); + allResults.push(...results.map(skillUri => ({ uri: skillUri, type: skillType }))); } } catch (error) { this.logService.error(`Failed to resolve agent skills location: ${location}`, error); From 2af1f761c8a180bb20ba473fb73db9eacab7c9a7 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 9 Jan 2026 17:04:35 -0800 Subject: [PATCH 3/4] update --- .../contrib/chat/browser/chat.contribution.ts | 4 +- .../chat/common/promptSyntax/config/config.ts | 8 +- .../promptSyntax/service/promptsService.ts | 2 +- .../service/promptsServiceImpl.ts | 4 +- .../promptSyntax/utils/promptFilesLocator.ts | 11 +-- .../common/promptSyntax/config/config.test.ts | 54 ++++++++++-- .../service/promptsService.test.ts | 82 +++++++++++++++++++ 7 files changed, 143 insertions(+), 22 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 6c8d42fe4b46f..0ff3bdf8a5739 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -717,12 +717,12 @@ configurationRegistry.registerConfiguration({ type: 'array', items: { type: 'string' }, title: nls.localize('chat.agentSkillsLocations.title', "Agent Skills Locations",), - markdownDescription: nls.localize('chat.agentSkillsLocations.description', "Specify additional folder locations to load agent skills from. Each folder should contain subdirectories with `SKILL.md` files. Relative paths are resolved from the root folder(s) of your workspace. Absolute paths can also be used.",), + markdownDescription: nls.localize('chat.agentSkillsLocations.description', "Specify additional folders containing agent skills. Each folder should have skill subdirectories with `SKILL.md` files (e.g., for `my-skills/skillA/SKILL.md`, add `my-skills`). Relative paths are resolved from workspace root(s). Absolute paths and `~` paths are also supported.",), default: [], restricted: true, tags: ['experimental', 'prompts', 'reusable prompts', 'prompt snippets', 'instructions'], examples: [ - ['/Users/vscode/my-skills'], + ['~/my-skills'], ['my-project-skills', '/shared/team-skills'], ], }, diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts index ffbf8497c0e70..2002ff7fdc1a6 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts @@ -5,6 +5,7 @@ import type { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; import { URI } from '../../../../../../base/common/uri.js'; +import { untildify } from '../../../../../../base/common/labels.js'; import { PromptsType } from '../promptTypes.js'; import { INSTRUCTIONS_DEFAULT_SOURCE_FOLDER, PROMPT_DEFAULT_SOURCE_FOLDER, getPromptFileDefaultLocation } from './promptFileLocations.js'; @@ -209,10 +210,12 @@ export namespace PromptsConfig { /** * Get list of additional agent skills folder locations from configuration. * Returns an array of folder paths where skills should be loaded from. + * Paths starting with `~/` are expanded using the provided user home path. * @param configService Configuration service instance + * @param userHomePath The user's home directory path for expanding `~/` prefixes * @see {@link AGENT_SKILLS_LOCATIONS_KEY}. */ - export function getAgentSkillsLocations(configService: IConfigurationService): string[] { + export function getAgentSkillsLocations(configService: IConfigurationService, userHomePath: string): string[] { const configValue = configService.getValue(AGENT_SKILLS_LOCATIONS_KEY); if (!Array.isArray(configValue)) { @@ -224,7 +227,8 @@ export namespace PromptsConfig { if (typeof path === 'string') { const cleanPath = path.trim(); if (cleanPath) { - result.push(cleanPath); + // Expand ~/... paths to use the user's home directory + result.push(untildify(cleanPath, userHomePath)); } } } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts index db0c3e5894869..b5db9ce8c7bfa 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsService.ts @@ -200,7 +200,7 @@ export interface IChatPromptSlashCommand { export interface IAgentSkill { readonly uri: URI; - readonly type: 'personal' | 'project'; + readonly type: 'personal' | 'project' | 'config'; readonly name: string; readonly description: string | undefined; } diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts index a5a5805870dd6..ba430434ed529 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/service/promptsServiceImpl.ts @@ -627,7 +627,7 @@ export class PromptsService extends Disposable implements IPromptsService { let skippedDuplicateName = 0; let skippedParseFailed = 0; - const process = async (uri: URI, skillType: string, scopeType: 'personal' | 'project'): Promise => { + const process = async (uri: URI, skillType: string, scopeType: 'personal' | 'project' | 'config'): Promise => { try { const parsedFile = await this.parseNew(uri, token); const name = parsedFile.header?.name; @@ -662,6 +662,8 @@ export class PromptsService extends Disposable implements IPromptsService { await Promise.all(workspaceSkills.map(({ uri, type }) => process(uri, type, 'project'))); const userSkills = await this.fileLocator.findAgentSkillsInUserHome(token); await Promise.all(userSkills.map(({ uri, type }) => process(uri, type, 'personal'))); + const configuredSkills = await this.fileLocator.findAgentSkillsInConfiguredLocations(token); + await Promise.all(configuredSkills.map(({ uri, type }) => process(uri, type, 'config'))); // Send telemetry about skill usage type AgentSkillsFoundEvent = { diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 5f0309357e3df..76616db64ffe2 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -404,11 +404,6 @@ export class PromptFilesLocator { allResults.push(...results.map(uri => ({ uri, type }))); } } - - // Also search in additional configured skill folders - const additionalFolders = await this.findAgentSkillsInConfiguredLocations(token); - allResults.push(...additionalFolders); - return allResults; } @@ -416,15 +411,15 @@ export class PromptFilesLocator { * Searches for skills in additional folders configured via the `chat.agentSkillsLocations` setting. * Each skill is stored in its own subdirectory with a SKILL.md file. */ - private async findAgentSkillsInConfiguredLocations(token: CancellationToken): Promise> { - const configuredLocations = PromptsConfig.getAgentSkillsLocations(this.configService); + public async findAgentSkillsInConfiguredLocations(token: CancellationToken): Promise> { + const userHome = await this.pathService.userHome(); + const configuredLocations = PromptsConfig.getAgentSkillsLocations(this.configService, userHome.fsPath); if (configuredLocations.length === 0) { return []; } const allResults: Array<{ uri: URI; type: string }> = []; const { folders } = this.workspaceService.getWorkspace(); - const userHome = await this.pathService.userHome(); for (const location of configuredLocations) { try { diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts index cc24e88824c33..13d2c5a5f535c 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/config/config.test.ts @@ -334,11 +334,13 @@ suite('PromptsConfig', () => { }); suite('getAgentSkillsLocations', () => { + const userHomePath = '/home/testuser'; + test('undefined returns empty array', () => { const configService = createMock(undefined); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [], 'Must return empty array for undefined value.', ); @@ -348,7 +350,7 @@ suite('PromptsConfig', () => { const configService = createMock(null); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [], 'Must return empty array for null value.', ); @@ -358,7 +360,7 @@ suite('PromptsConfig', () => { const configService = createMock({ path: '/some/path' }); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [], 'Must return empty array for non-array value.', ); @@ -368,7 +370,7 @@ suite('PromptsConfig', () => { const configService = createMock([]); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [], 'Must return empty array for empty array value.', ); @@ -382,7 +384,7 @@ suite('PromptsConfig', () => { ]); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [ '/absolute/path/to/skills', 'relative/path/to/skills', @@ -402,7 +404,7 @@ suite('PromptsConfig', () => { ]); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [ '/valid/path', 'another/valid/path', @@ -422,7 +424,7 @@ suite('PromptsConfig', () => { ]); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [ '/valid/path', 'another/valid/path', @@ -439,7 +441,7 @@ suite('PromptsConfig', () => { ]); assert.deepStrictEqual( - PromptsConfig.getAgentSkillsLocations(configService), + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), [ '/path/with/leading/space', '/path/with/trailing/space', @@ -448,5 +450,41 @@ suite('PromptsConfig', () => { 'Must trim whitespace from paths.', ); }); + + test('expands tilde paths to user home directory', () => { + const configService = createMock([ + '~/my-skills', + '~/.claude/skills', + '~/nested/path/to/skills', + ]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), + [ + '/home/testuser/my-skills', + '/home/testuser/.claude/skills', + '/home/testuser/nested/path/to/skills', + ], + 'Must expand tilde paths to user home directory.', + ); + }); + + test('handles mixed tilde and non-tilde paths', () => { + const configService = createMock([ + '~/personal-skills', + '/absolute/path', + 'relative/path', + ]); + + assert.deepStrictEqual( + PromptsConfig.getAgentSkillsLocations(configService, userHomePath), + [ + '/home/testuser/personal-skills', + '/absolute/path', + 'relative/path', + ], + 'Must handle mixed tilde and non-tilde paths.', + ); + }); }); }); diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index 8179652e429db..b06d92d22fffd 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -1572,5 +1572,87 @@ suite('PromptsService', () => { assert.ok(!result[0].description?.includes('>'), 'Description should not contain XML tags'); assert.strictEqual(result[0].description?.length, 1024, 'Description should be truncated to 1024 characters'); }); + + test('should find skills in configured locations', async () => { + testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); + + const rootFolderName = 'configured-skills-test'; + const rootFolder = `/${rootFolderName}`; + const rootFolderUri = URI.file(rootFolder); + + workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); + + // Configure additional skills locations + testConfigService.setUserConfiguration(PromptsConfig.AGENT_SKILLS_LOCATIONS_KEY, [ + 'custom-skills', // relative path in workspace + '/absolute/skill-folder', // absolute path + ]); + + // Create mock filesystem with skills in configured locations + await mockFiles(fileService, [ + // Skill in relative path (workspace folder) + { + path: `${rootFolder}/custom-skills/workspace-skill/SKILL.md`, + contents: [ + '---', + 'name: "Custom Workspace Skill"', + 'description: "A skill from a custom workspace folder"', + '---', + 'This is workspace skill content', + ], + }, + // Skill in absolute path + { + path: '/absolute/skill-folder/absolute-skill/SKILL.md', + contents: [ + '---', + 'name: "Absolute Path Skill"', + 'description: "A skill from an absolute path"', + '---', + 'This is absolute path skill content', + ], + }, + // Another skill in absolute path + { + path: '/absolute/skill-folder/another-skill/SKILL.md', + contents: [ + '---', + 'name: "Another Absolute Skill"', + 'description: "Another skill from an absolute path"', + '---', + 'This is another absolute skill content', + ], + }, + // A folder without SKILL.md (should be ignored) + { + path: '/absolute/skill-folder/not-a-skill/README.md', + contents: ['This is not a skill'], + }, + ]); + + const result = await service.findAgentSkills(CancellationToken.None); + + assert.ok(result, 'Should return results when agent skills are enabled'); + assert.strictEqual(result.length, 3, 'Should find 3 skills from configured locations'); + + // Check config skills + const configSkills = result.filter(skill => skill.type === 'config'); + assert.strictEqual(configSkills.length, 3, 'Should find 3 config skills'); + + const workspaceSkill = configSkills.find(skill => skill.name === 'Custom Workspace Skill'); + assert.ok(workspaceSkill, 'Should find Custom Workspace Skill'); + assert.strictEqual(workspaceSkill.description, 'A skill from a custom workspace folder'); + assert.strictEqual(workspaceSkill.uri.path, `${rootFolder}/custom-skills/workspace-skill/SKILL.md`); + + const absoluteSkill = configSkills.find(skill => skill.name === 'Absolute Path Skill'); + assert.ok(absoluteSkill, 'Should find Absolute Path Skill'); + assert.strictEqual(absoluteSkill.description, 'A skill from an absolute path'); + assert.strictEqual(absoluteSkill.uri.path, '/absolute/skill-folder/absolute-skill/SKILL.md'); + + const anotherSkill = configSkills.find(skill => skill.name === 'Another Absolute Skill'); + assert.ok(anotherSkill, 'Should find Another Absolute Skill'); + assert.strictEqual(anotherSkill.description, 'Another skill from an absolute path'); + assert.strictEqual(anotherSkill.uri.path, '/absolute/skill-folder/another-skill/SKILL.md'); + }); }); }); From da8dae8c816e4d4cd0ba124578fe409cfd91abb3 Mon Sep 17 00:00:00 2001 From: Paul Wang Date: Fri, 9 Jan 2026 17:08:16 -0800 Subject: [PATCH 4/4] clean --- .../promptSyntax/utils/promptFilesLocator.ts | 30 +------------------ 1 file changed, 1 insertion(+), 29 deletions(-) diff --git a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts index 76616db64ffe2..b7d40dd95c3fe 100644 --- a/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts +++ b/src/vs/workbench/contrib/chat/common/promptSyntax/utils/promptFilesLocator.ts @@ -445,7 +445,7 @@ export class PromptFilesLocator { } for (const uri of uris) { - const results = await this.findAgentSkillsInFolderDirect(uri, token); + const results = await this.findAgentSkillsInFolder(uri, '', token); allResults.push(...results.map(skillUri => ({ uri: skillUri, type: skillType }))); } } catch (error) { @@ -456,34 +456,6 @@ export class PromptFilesLocator { return allResults; } - /** - * Finds skills directly in a folder (the folder itself contains skill subdirectories). - */ - private async findAgentSkillsInFolderDirect(folderUri: URI, token: CancellationToken): Promise { - const result: URI[] = []; - try { - const stat = await this.fileService.resolve(folderUri); - if (token.isCancellationRequested) { - return []; - } - if (stat.isDirectory && stat.children) { - for (const skillDir of stat.children) { - if (skillDir.isDirectory) { - const skillFile = joinPath(skillDir.resource, 'SKILL.md'); - if (await this.fileService.exists(skillFile)) { - result.push(skillFile); - } - } - } - } - } catch (error) { - // No such folder, return empty list - return []; - } - - return result; - } - /** * Searches for skills in all default directories in the home folder. * Each skill is stored in its own subdirectory with a SKILL.md file.