Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
15 changes: 14 additions & 1 deletion src/vs/workbench/contrib/chat/browser/chat.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 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: [
['~/my-skills'],
['my-project-skills', '/shared/team-skills'],
],
},
[PromptsConfig.PROMPT_FILES_SUGGEST_KEY]: {
type: 'object',
scope: ConfigurationScope.RESOURCE,
Expand Down
35 changes: 35 additions & 0 deletions src/vs/workbench/contrib/chat/common/promptSyntax/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -83,6 +84,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}.
Expand Down Expand Up @@ -201,6 +207,35 @@ 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.
* 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, userHomePath: string): string[] {
const configValue = configService.getValue<string[]>(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) {
// Expand ~/... paths to use the user's home directory
result.push(untildify(cleanPath, userHomePath));
}
}
}

return result;
}

}

export function getPromptFileLocationsConfigKey(type: PromptsType): string {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
const process = async (uri: URI, skillType: string, scopeType: 'personal' | 'project' | 'config'): Promise<void> => {
try {
const parsedFile = await this.parseNew(uri, token);
const name = parsedFile.header?.name;
Expand Down Expand Up @@ -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 = {
Expand All @@ -672,6 +674,7 @@ export class PromptsService extends Disposable implements IPromptsService {
githubWorkspace: number;
customPersonal: number;
customWorkspace: number;
customOther: number;
skippedDuplicateName: number;
skippedMissingName: number;
skippedParseFailed: number;
Expand All @@ -685,6 +688,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.' };
Expand All @@ -700,6 +704,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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -407,6 +407,55 @@ export class PromptFilesLocator {
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.
*/
public async findAgentSkillsInConfiguredLocations(token: CancellationToken): Promise<Array<{ uri: URI; type: string }>> {
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();

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;
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];
// 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.findAgentSkillsInFolder(uri, '', token);
allResults.push(...results.map(skillUri => ({ uri: skillUri, type: skillType })));
}
} catch (error) {
this.logService.error(`Failed to resolve agent skills location: ${location}`, error);
}
}

return allResults;
}

/**
* Searches for skills in all default directories in the home folder.
* Each skill is stored in its own subdirectory with a SKILL.md file.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function createMock<T>(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}'.`,
);

Expand Down Expand Up @@ -332,4 +332,159 @@ suite('PromptsConfig', () => {
});
});
});

suite('getAgentSkillsLocations', () => {
const userHomePath = '/home/testuser';

test('undefined returns empty array', () => {
const configService = createMock(undefined);

assert.deepStrictEqual(
PromptsConfig.getAgentSkillsLocations(configService, userHomePath),
[],
'Must return empty array for undefined value.',
);
});

test('null returns empty array', () => {
const configService = createMock(null);

assert.deepStrictEqual(
PromptsConfig.getAgentSkillsLocations(configService, userHomePath),
[],
'Must return empty array for null value.',
);
});

test('non-array returns empty array', () => {
const configService = createMock({ path: '/some/path' });

assert.deepStrictEqual(
PromptsConfig.getAgentSkillsLocations(configService, userHomePath),
[],
'Must return empty array for non-array value.',
);
});

test('empty array returns empty array', () => {
const configService = createMock([]);

assert.deepStrictEqual(
PromptsConfig.getAgentSkillsLocations(configService, userHomePath),
[],
'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, userHomePath),
[
'/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, userHomePath),
[
'/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, userHomePath),
[
'/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, userHomePath),
[
'/path/with/leading/space',
'/path/with/trailing/space',
'/path/with/both',
],
'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.',
);
});
});
});
Loading
Loading