mirror of
https://github.com/microsoft/vscode.git
synced 2026-02-15 07:28:05 +00:00
Agent skills management UX and file support (#287201)
This commit is contained in:
@@ -20,6 +20,7 @@
|
||||
"onLanguage:prompt",
|
||||
"onLanguage:instructions",
|
||||
"onLanguage:chatagent",
|
||||
"onLanguage:skill",
|
||||
"onCommand:markdown.api.render",
|
||||
"onCommand:markdown.api.reloadPlugins",
|
||||
"onWebviewPanel:markdown.preview"
|
||||
@@ -181,13 +182,13 @@
|
||||
"command": "markdown.editor.insertLinkFromWorkspace",
|
||||
"title": "%markdown.editor.insertLinkFromWorkspace%",
|
||||
"category": "Markdown",
|
||||
"enablement": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !activeEditorIsReadonly"
|
||||
"enablement": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !activeEditorIsReadonly"
|
||||
},
|
||||
{
|
||||
"command": "markdown.editor.insertImageFromWorkspace",
|
||||
"title": "%markdown.editor.insertImageFromWorkspace%",
|
||||
"category": "Markdown",
|
||||
"enablement": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !activeEditorIsReadonly"
|
||||
"enablement": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !activeEditorIsReadonly"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
@@ -204,7 +205,7 @@
|
||||
"editor/title": [
|
||||
{
|
||||
"command": "markdown.showPreviewToSide",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused && !hasCustomMarkdownPreview",
|
||||
"alt": "markdown.showPreview",
|
||||
"group": "navigation"
|
||||
},
|
||||
@@ -232,24 +233,24 @@
|
||||
"explorer/context": [
|
||||
{
|
||||
"command": "markdown.showPreview",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !hasCustomMarkdownPreview",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !hasCustomMarkdownPreview",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "markdown.findAllFileReferences",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent)$/",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/",
|
||||
"group": "4_search"
|
||||
}
|
||||
],
|
||||
"editor/title/context": [
|
||||
{
|
||||
"command": "markdown.showPreview",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !hasCustomMarkdownPreview",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !hasCustomMarkdownPreview",
|
||||
"group": "1_open"
|
||||
},
|
||||
{
|
||||
"command": "markdown.findAllFileReferences",
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent)$/"
|
||||
"when": "resourceLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/"
|
||||
}
|
||||
],
|
||||
"commandPalette": [
|
||||
@@ -263,17 +264,17 @@
|
||||
},
|
||||
{
|
||||
"command": "markdown.showPreview",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "markdown.showPreviewToSide",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
"command": "markdown.showLockedPreviewToSide",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused",
|
||||
"group": "navigation"
|
||||
},
|
||||
{
|
||||
@@ -283,7 +284,7 @@
|
||||
},
|
||||
{
|
||||
"command": "markdown.showPreviewSecuritySelector",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused"
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"
|
||||
},
|
||||
{
|
||||
"command": "markdown.showPreviewSecuritySelector",
|
||||
@@ -295,7 +296,7 @@
|
||||
},
|
||||
{
|
||||
"command": "markdown.preview.refresh",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused"
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"
|
||||
},
|
||||
{
|
||||
"command": "markdown.preview.refresh",
|
||||
@@ -303,7 +304,7 @@
|
||||
},
|
||||
{
|
||||
"command": "markdown.findAllFileReferences",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/"
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -312,13 +313,13 @@
|
||||
"command": "markdown.showPreview",
|
||||
"key": "shift+ctrl+v",
|
||||
"mac": "shift+cmd+v",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused"
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"
|
||||
},
|
||||
{
|
||||
"command": "markdown.showPreviewToSide",
|
||||
"key": "ctrl+k v",
|
||||
"mac": "cmd+k v",
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent)$/ && !notebookEditorFocused"
|
||||
"when": "editorLangId =~ /^(markdown|prompt|instructions|chatagent|skill)$/ && !notebookEditorFocused"
|
||||
}
|
||||
],
|
||||
"configuration": {
|
||||
|
||||
@@ -19,7 +19,7 @@ export const markdownFileExtensions = Object.freeze<string[]>([
|
||||
'workbook',
|
||||
]);
|
||||
|
||||
export const markdownLanguageIds = ['markdown', 'prompt', 'instructions', 'chatagent'];
|
||||
export const markdownLanguageIds = ['markdown', 'prompt', 'instructions', 'chatagent', 'skill'];
|
||||
|
||||
export function isMarkdownFile(document: vscode.TextDocument) {
|
||||
return markdownLanguageIds.indexOf(document.languageId) !== -1;
|
||||
|
||||
@@ -50,6 +50,17 @@
|
||||
"**/.github/agents/*.md"
|
||||
],
|
||||
"configuration": "./language-configuration.json"
|
||||
},
|
||||
{
|
||||
"id": "skill",
|
||||
"aliases": [
|
||||
"Skill",
|
||||
"skill"
|
||||
],
|
||||
"filenames": [
|
||||
"SKILL.md"
|
||||
],
|
||||
"configuration": "./language-configuration.json"
|
||||
}
|
||||
],
|
||||
"grammars": [
|
||||
@@ -79,6 +90,15 @@
|
||||
"markup.underline.link.markdown",
|
||||
"punctuation.definition.list.begin.markdown"
|
||||
]
|
||||
},
|
||||
{
|
||||
"language": "skill",
|
||||
"path": "./syntaxes/prompt.tmLanguage.json",
|
||||
"scopeName": "text.html.markdown.prompt",
|
||||
"unbalancedBracketScopes": [
|
||||
"markup.underline.link.markdown",
|
||||
"punctuation.definition.list.begin.markdown"
|
||||
]
|
||||
}
|
||||
],
|
||||
"configurationDefaults": {
|
||||
@@ -126,6 +146,21 @@
|
||||
"other": "on"
|
||||
},
|
||||
"editor.wordBasedSuggestions": "off"
|
||||
},
|
||||
"[skill]": {
|
||||
"editor.insertSpaces": true,
|
||||
"editor.tabSize": 2,
|
||||
"editor.autoIndent": "advanced",
|
||||
"editor.unicodeHighlight.ambiguousCharacters": false,
|
||||
"editor.unicodeHighlight.invisibleCharacters": false,
|
||||
"diffEditor.ignoreTrimWhitespace": false,
|
||||
"editor.wordWrap": "on",
|
||||
"editor.quickSuggestions": {
|
||||
"comments": "off",
|
||||
"strings": "on",
|
||||
"other": "on"
|
||||
},
|
||||
"editor.wordBasedSuggestions": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,6 +25,8 @@ import { CHAT_CATEGORY } from '../actions/chatActions.js';
|
||||
import { askForPromptFileName } from './pickers/askForPromptName.js';
|
||||
import { askForPromptSourceFolder } from './pickers/askForPromptSourceFolder.js';
|
||||
import { IChatModeService } from '../../common/chatModes.js';
|
||||
import { IQuickInputService } from '../../../../../platform/quickinput/common/quickInput.js';
|
||||
import { SKILL_FILENAME } from '../../common/promptSyntax/config/promptFileLocations.js';
|
||||
|
||||
|
||||
class AbstractNewPromptFileAction extends Action2 {
|
||||
@@ -165,14 +167,30 @@ function getDefaultContentSnippet(promptType: PromptsType, chatModeService: ICha
|
||||
`\${2:Define what this custom agent accomplishes for the user, when to use it, and the edges it won't cross. Specify its ideal inputs/outputs, the tools it may call, and how it reports progress or asks for help.}`,
|
||||
].join('\n');
|
||||
default:
|
||||
throw new Error(`Unknown prompt type: ${promptType}`);
|
||||
throw new Error(`Unsupported prompt type: ${promptType}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates the content snippet for a skill file with the name pre-populated.
|
||||
* Per agentskills.io/specification, the name field must match the parent directory name.
|
||||
*/
|
||||
function getSkillContentSnippet(skillName: string): string {
|
||||
return [
|
||||
`---`,
|
||||
`name: ${skillName}`,
|
||||
`description: '\${1:Describe what this skill does and when to use it. Include keywords that help agents identify relevant tasks.}'`,
|
||||
`---`,
|
||||
``,
|
||||
`\${2:Provide detailed instructions for the agent. Include step-by-step guidance, examples, and edge cases.}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
export const NEW_PROMPT_COMMAND_ID = 'workbench.command.new.prompt';
|
||||
export const NEW_INSTRUCTIONS_COMMAND_ID = 'workbench.command.new.instructions';
|
||||
export const NEW_AGENT_COMMAND_ID = 'workbench.command.new.agent';
|
||||
export const NEW_SKILL_COMMAND_ID = 'workbench.command.new.skill';
|
||||
|
||||
class NewPromptFileAction extends AbstractNewPromptFileAction {
|
||||
constructor() {
|
||||
@@ -192,6 +210,89 @@ class NewAgentFileAction extends AbstractNewPromptFileAction {
|
||||
}
|
||||
}
|
||||
|
||||
class NewSkillFileAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: NEW_SKILL_COMMAND_ID,
|
||||
title: localize('commands.new.skill.local.title', "New Skill File..."),
|
||||
f1: false,
|
||||
precondition: ChatContextKeys.enabled,
|
||||
category: CHAT_CATEGORY,
|
||||
keybinding: {
|
||||
weight: KeybindingWeight.WorkbenchContrib
|
||||
},
|
||||
menu: {
|
||||
id: MenuId.CommandPalette,
|
||||
when: ChatContextKeys.enabled
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override async run(accessor: ServicesAccessor) {
|
||||
const openerService = accessor.get(IOpenerService);
|
||||
const editorService = accessor.get(IEditorService);
|
||||
const fileService = accessor.get(IFileService);
|
||||
const instaService = accessor.get(IInstantiationService);
|
||||
const quickInputService = accessor.get(IQuickInputService);
|
||||
|
||||
const selectedFolder = await instaService.invokeFunction(askForPromptSourceFolder, PromptsType.skill);
|
||||
if (!selectedFolder) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ask for skill name (will be the folder name)
|
||||
// Per agentskills.io/specification: name must be 1-64 chars, lowercase alphanumeric + hyphens,
|
||||
// no leading/trailing hyphens, no consecutive hyphens, must match folder name
|
||||
const skillName = await quickInputService.input({
|
||||
prompt: localize('commands.new.skill.name.prompt', "Enter a name for the skill (lowercase letters, numbers, and hyphens only)"),
|
||||
placeHolder: localize('commands.new.skill.name.placeholder', "e.g., pdf-processing, data-analysis"),
|
||||
validateInput: async (value) => {
|
||||
if (!value || !value.trim()) {
|
||||
return localize('commands.new.skill.name.required', "Skill name is required");
|
||||
}
|
||||
const name = value.trim();
|
||||
if (name.length > 64) {
|
||||
return localize('commands.new.skill.name.tooLong', "Skill name must be 64 characters or less");
|
||||
}
|
||||
// Per spec: lowercase alphanumeric and hyphens only
|
||||
if (!/^[a-z0-9-]+$/.test(name)) {
|
||||
return localize('commands.new.skill.name.invalidChars', "Skill name may only contain lowercase letters, numbers, and hyphens");
|
||||
}
|
||||
if (name.startsWith('-') || name.endsWith('-')) {
|
||||
return localize('commands.new.skill.name.hyphenEdge', "Skill name must not start or end with a hyphen");
|
||||
}
|
||||
if (name.includes('--')) {
|
||||
return localize('commands.new.skill.name.consecutiveHyphens', "Skill name must not contain consecutive hyphens");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
|
||||
if (!skillName) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = skillName.trim();
|
||||
|
||||
// Create the skill folder and SKILL.md file
|
||||
const skillFolder = URI.joinPath(selectedFolder.uri, trimmedName);
|
||||
await fileService.createFolder(skillFolder);
|
||||
|
||||
const skillFileUri = URI.joinPath(skillFolder, SKILL_FILENAME);
|
||||
await fileService.createFile(skillFileUri);
|
||||
|
||||
await openerService.open(skillFileUri);
|
||||
|
||||
const editor = getCodeEditor(editorService.activeTextEditorControl);
|
||||
if (editor && editor.hasModel() && isEqual(editor.getModel().uri, skillFileUri)) {
|
||||
SnippetController2.get(editor)?.apply([{
|
||||
range: editor.getModel().getFullModelRange(),
|
||||
template: getSkillContentSnippet(trimmedName),
|
||||
}]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class NewUntitledPromptFileAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
@@ -237,5 +338,6 @@ export function registerNewPromptFileActions(): void {
|
||||
registerAction2(NewPromptFileAction);
|
||||
registerAction2(NewInstructionsFileAction);
|
||||
registerAction2(NewAgentFileAction);
|
||||
registerAction2(NewSkillFileAction);
|
||||
registerAction2(NewUntitledPromptFileAction);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,8 @@ function getPlaceholderStringforNew(type: PromptsType): string {
|
||||
return localize('workbench.command.prompt.create.location.placeholder', "Select a location to create the prompt file");
|
||||
case PromptsType.agent:
|
||||
return localize('workbench.command.agent.create.location.placeholder', "Select a location to create the agent file");
|
||||
case PromptsType.skill:
|
||||
return localize('workbench.command.skill.create.location.placeholder', "Select a location to create the skill");
|
||||
default:
|
||||
throw new Error('Unknown prompt type');
|
||||
}
|
||||
@@ -125,6 +127,8 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string
|
||||
return localize('prompt.move.location.placeholder', "Select a location to move the prompt file to");
|
||||
case PromptsType.agent:
|
||||
return localize('agent.move.location.placeholder', "Select a location to move the agent file to");
|
||||
case PromptsType.skill:
|
||||
return localize('skill.move.location.placeholder', "Select a location to move the skill to");
|
||||
default:
|
||||
throw new Error('Unknown prompt type');
|
||||
}
|
||||
@@ -136,6 +140,8 @@ function getPlaceholderStringforMove(type: PromptsType, isMove: boolean): string
|
||||
return localize('prompt.copy.location.placeholder', "Select a location to copy the prompt file to");
|
||||
case PromptsType.agent:
|
||||
return localize('agent.copy.location.placeholder', "Select a location to copy the agent file to");
|
||||
case PromptsType.skill:
|
||||
return localize('skill.copy.location.placeholder', "Select a location to copy the skill to");
|
||||
default:
|
||||
throw new Error('Unknown prompt type');
|
||||
}
|
||||
@@ -179,6 +185,8 @@ function getLearnLabel(type: PromptsType): string {
|
||||
return localize('commands.instructions.create.ask-folder.empty.docs-label', 'Learn how to configure reusable instructions');
|
||||
case PromptsType.agent:
|
||||
return localize('commands.agent.create.ask-folder.empty.docs-label', 'Learn how to configure custom agents');
|
||||
case PromptsType.skill:
|
||||
return localize('commands.skill.create.ask-folder.empty.docs-label', 'Learn how to configure skills');
|
||||
default:
|
||||
throw new Error('Unknown prompt type');
|
||||
}
|
||||
@@ -192,6 +200,8 @@ function getMissingSourceFolderString(type: PromptsType): string {
|
||||
return localize('commands.prompts.create.ask-folder.empty.placeholder', 'No prompt source folders found.');
|
||||
case PromptsType.agent:
|
||||
return localize('commands.agent.create.ask-folder.empty.placeholder', 'No agent source folders found.');
|
||||
case PromptsType.skill:
|
||||
return localize('commands.skill.create.ask-folder.empty.placeholder', 'No skill source folders found.');
|
||||
default:
|
||||
throw new Error('Unknown prompt type');
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { IDialogService } from '../../../../../../platform/dialogs/common/dialog
|
||||
import { ICommandService } from '../../../../../../platform/commands/common/commands.js';
|
||||
import { getCleanPromptName } from '../../../common/promptSyntax/config/promptFileLocations.js';
|
||||
import { PromptsType, INSTRUCTIONS_DOCUMENTATION_URL, AGENT_DOCUMENTATION_URL, PROMPT_DOCUMENTATION_URL, SKILL_DOCUMENTATION_URL } from '../../../common/promptSyntax/promptTypes.js';
|
||||
import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID } from '../newPromptFileActions.js';
|
||||
import { NEW_PROMPT_COMMAND_ID, NEW_INSTRUCTIONS_COMMAND_ID, NEW_AGENT_COMMAND_ID, NEW_SKILL_COMMAND_ID } from '../newPromptFileActions.js';
|
||||
import { IKeyMods, IQuickInputButton, IQuickInputService, IQuickPick, IQuickPickItem, IQuickPickItemButtonEvent, IQuickPickSeparator } from '../../../../../../platform/quickinput/common/quickInput.js';
|
||||
import { askForPromptFileName } from './askForPromptName.js';
|
||||
import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js';
|
||||
@@ -184,6 +184,21 @@ const NEW_AGENT_FILE_OPTION: IPromptPickerQuickPickItem = {
|
||||
commandId: NEW_AGENT_COMMAND_ID,
|
||||
};
|
||||
|
||||
/**
|
||||
* A quick pick item that starts the 'New Skill' command.
|
||||
*/
|
||||
const NEW_SKILL_FILE_OPTION: IPromptPickerQuickPickItem = {
|
||||
type: 'item',
|
||||
label: `$(plus) ${localize(
|
||||
'commands.new-skill.select-dialog.label',
|
||||
'New skill...',
|
||||
)}`,
|
||||
pickable: false,
|
||||
alwaysShow: true,
|
||||
buttons: [newHelpButton(PromptsType.skill)],
|
||||
commandId: NEW_SKILL_COMMAND_ID,
|
||||
};
|
||||
|
||||
/**
|
||||
* Button that opens a prompt file in the editor.
|
||||
*/
|
||||
@@ -419,6 +434,8 @@ export class PromptFilePickers {
|
||||
return [NEW_INSTRUCTIONS_FILE_OPTION, UPDATE_INSTRUCTIONS_OPTION];
|
||||
case PromptsType.agent:
|
||||
return [NEW_AGENT_FILE_OPTION];
|
||||
case PromptsType.skill:
|
||||
return [NEW_SKILL_FILE_OPTION];
|
||||
default:
|
||||
throw new Error(`Unknown prompt type '${type}'.`);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { registerAttachPromptActions } from './attachInstructionsAction.js';
|
||||
import { registerAgentActions } from './chatModeActions.js';
|
||||
import { registerRunPromptActions } from './runPromptAction.js';
|
||||
import { registerNewPromptFileActions } from './newPromptFileActions.js';
|
||||
import { registerSkillActions } from './skillActions.js';
|
||||
import { registerAction2 } from '../../../../../platform/actions/common/actions.js';
|
||||
import { SaveAsAgentFileAction, SaveAsInstructionsFileAction, SaveAsPromptFileAction } from './saveAsPromptFileActions.js';
|
||||
|
||||
@@ -17,6 +18,7 @@ import { SaveAsAgentFileAction, SaveAsInstructionsFileAction, SaveAsPromptFileAc
|
||||
export function registerPromptActions(): void {
|
||||
registerRunPromptActions();
|
||||
registerAttachPromptActions();
|
||||
registerSkillActions();
|
||||
registerAction2(SaveAsPromptFileAction);
|
||||
registerAction2(SaveAsInstructionsFileAction);
|
||||
registerAction2(SaveAsAgentFileAction);
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
* Licensed under the MIT License. See License.txt in the project root for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
import { ChatViewId } from '../chat.js';
|
||||
import { CHAT_CATEGORY, CHAT_CONFIG_MENU_ID } from '../actions/chatActions.js';
|
||||
import { localize, localize2 } from '../../../../../nls.js';
|
||||
import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';
|
||||
import { PromptFilePickers } from './pickers/promptFilePickers.js';
|
||||
import { ServicesAccessor } from '../../../../../editor/browser/editorExtensions.js';
|
||||
import { Action2, registerAction2 } from '../../../../../platform/actions/common/actions.js';
|
||||
import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js';
|
||||
import { Codicon } from '../../../../../base/common/codicons.js';
|
||||
import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js';
|
||||
import { PromptsType } from '../../common/promptSyntax/promptTypes.js';
|
||||
import { IOpenerService } from '../../../../../platform/opener/common/opener.js';
|
||||
|
||||
/**
|
||||
* Action ID for the `Configure Skills` action.
|
||||
*/
|
||||
const CONFIGURE_SKILLS_ACTION_ID = 'workbench.action.chat.configure.skills';
|
||||
|
||||
|
||||
class ManageSkillsAction extends Action2 {
|
||||
constructor() {
|
||||
super({
|
||||
id: CONFIGURE_SKILLS_ACTION_ID,
|
||||
title: localize2('configure-skills', "Configure Skills..."),
|
||||
shortTitle: localize2('configure-skills.short', "Skills"),
|
||||
icon: Codicon.lightbulb,
|
||||
f1: true,
|
||||
precondition: ChatContextKeys.enabled,
|
||||
category: CHAT_CATEGORY,
|
||||
menu: {
|
||||
id: CHAT_CONFIG_MENU_ID,
|
||||
when: ContextKeyExpr.and(ChatContextKeys.enabled, ContextKeyExpr.equals('view', ChatViewId)),
|
||||
order: 9,
|
||||
group: '1_level'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public override async run(
|
||||
accessor: ServicesAccessor,
|
||||
): Promise<void> {
|
||||
const openerService = accessor.get(IOpenerService);
|
||||
const instaService = accessor.get(IInstantiationService);
|
||||
|
||||
const pickers = instaService.createInstance(PromptFilePickers);
|
||||
|
||||
const placeholder = localize(
|
||||
'commands.prompt.manage-skills-dialog.placeholder',
|
||||
'Select the skill to open'
|
||||
);
|
||||
|
||||
const result = await pickers.selectPromptFile({ placeholder, type: PromptsType.skill, optionEdit: false });
|
||||
if (result !== undefined) {
|
||||
await openerService.open(result.promptFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to register the `Manage Skills` action.
|
||||
*/
|
||||
export function registerSkillActions(): void {
|
||||
registerAction2(ManageSkillsAction);
|
||||
}
|
||||
@@ -68,63 +68,78 @@ export class PromptHoverProvider implements HoverProvider {
|
||||
}
|
||||
|
||||
private async provideHeaderHover(position: Position, promptType: PromptsType, header: PromptHeader): Promise<Hover | undefined> {
|
||||
if (promptType === PromptsType.instructions) {
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.instructions.name', 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), attribute.range);
|
||||
case PromptHeaderAttributes.applyTo:
|
||||
return this.createHover(localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: `**/*.ts`, `**/*.js`, `client/**`'), attribute.range);
|
||||
switch (promptType) {
|
||||
case PromptsType.instructions:
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.instructions.name', 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.instructions.description', 'The description of the instruction file. It can be used to provide additional context or information about the instructions and is passed to the language model as part of the prompt.'), attribute.range);
|
||||
case PromptHeaderAttributes.applyTo:
|
||||
return this.createHover(localize('promptHeader.instructions.applyToRange', 'One or more glob pattern (separated by comma) that describe for which files the instructions apply to. Based on these patterns, the file is automatically included in the prompt, when the context contains a file that matches one or more of these patterns. Use `**` when you want this file to always be added.\nExample: `**/*.ts`, `**/*.js`, `client/**`'), attribute.range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (promptType === PromptsType.agent) {
|
||||
const isGitHubTarget = isGithubTarget(promptType, header.target);
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.agent.name', 'The name of the agent as shown in the UI.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.agent.description', 'The description of the custom agent, what it does and when to use it.'), attribute.range);
|
||||
case PromptHeaderAttributes.argumentHint:
|
||||
return this.createHover(localize('promptHeader.agent.argumentHint', 'The argument-hint describes what inputs the custom agent expects or supports.'), attribute.range);
|
||||
case PromptHeaderAttributes.model:
|
||||
return this.getModelHover(attribute, attribute.range, localize('promptHeader.agent.model', 'Specify the model that runs this custom agent.'), isGitHubTarget);
|
||||
case PromptHeaderAttributes.tools:
|
||||
return this.getToolHover(attribute, position, localize('promptHeader.agent.tools', 'The set of tools that the custom agent has access to.'));
|
||||
case PromptHeaderAttributes.handOffs:
|
||||
return this.getHandsOffHover(attribute, position, isGitHubTarget);
|
||||
case PromptHeaderAttributes.target:
|
||||
return this.createHover(localize('promptHeader.agent.target', 'The target to which the header attributes like tools apply to. Possible values are `github-copilot` and `vscode`.'), attribute.range);
|
||||
case PromptHeaderAttributes.infer:
|
||||
return this.createHover(localize('promptHeader.agent.infer', 'Whether the agent can be used as a subagent.'), attribute.range);
|
||||
break;
|
||||
case PromptsType.skill:
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.skill.name', 'The name of the skill.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.skill.description', 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.'), attribute.range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.prompt.name', 'The name of the prompt. This is also the name of the slash command that will run this prompt.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.prompt.description', 'The description of the reusable prompt, what it does and when to use it.'), attribute.range);
|
||||
case PromptHeaderAttributes.argumentHint:
|
||||
return this.createHover(localize('promptHeader.prompt.argumentHint', 'The argument-hint describes what inputs the prompt expects or supports.'), attribute.range);
|
||||
case PromptHeaderAttributes.model:
|
||||
return this.getModelHover(attribute, attribute.range, localize('promptHeader.prompt.model', 'The model to use in this prompt.'), false);
|
||||
case PromptHeaderAttributes.tools:
|
||||
return this.getToolHover(attribute, position, localize('promptHeader.prompt.tools', 'The tools to use in this prompt.'));
|
||||
case PromptHeaderAttributes.agent:
|
||||
case PromptHeaderAttributes.mode:
|
||||
return this.getAgentHover(attribute, position);
|
||||
break;
|
||||
case PromptsType.agent:
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.agent.name', 'The name of the agent as shown in the UI.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.agent.description', 'The description of the custom agent, what it does and when to use it.'), attribute.range);
|
||||
case PromptHeaderAttributes.argumentHint:
|
||||
return this.createHover(localize('promptHeader.agent.argumentHint', 'The argument-hint describes what inputs the custom agent expects or supports.'), attribute.range);
|
||||
case PromptHeaderAttributes.model:
|
||||
return this.getModelHover(attribute, attribute.range, localize('promptHeader.agent.model', 'Specify the model that runs this custom agent.'), isGithubTarget(promptType, header.target));
|
||||
case PromptHeaderAttributes.tools:
|
||||
return this.getToolHover(attribute, position, localize('promptHeader.agent.tools', 'The set of tools that the custom agent has access to.'));
|
||||
case PromptHeaderAttributes.handOffs:
|
||||
return this.getHandsOffHover(attribute, position, isGithubTarget(promptType, header.target));
|
||||
case PromptHeaderAttributes.target:
|
||||
return this.createHover(localize('promptHeader.agent.target', 'The target to which the header attributes like tools apply to. Possible values are `github-copilot` and `vscode`.'), attribute.range);
|
||||
case PromptHeaderAttributes.infer:
|
||||
return this.createHover(localize('promptHeader.agent.infer', 'Whether the agent can be used as a subagent.'), attribute.range);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
case PromptsType.prompt:
|
||||
for (const attribute of header.attributes) {
|
||||
if (attribute.range.containsPosition(position)) {
|
||||
switch (attribute.key) {
|
||||
case PromptHeaderAttributes.name:
|
||||
return this.createHover(localize('promptHeader.prompt.name', 'The name of the prompt. This is also the name of the slash command that will run this prompt.'), attribute.range);
|
||||
case PromptHeaderAttributes.description:
|
||||
return this.createHover(localize('promptHeader.prompt.description', 'The description of the reusable prompt, what it does and when to use it.'), attribute.range);
|
||||
case PromptHeaderAttributes.argumentHint:
|
||||
return this.createHover(localize('promptHeader.prompt.argumentHint', 'The argument-hint describes what inputs the prompt expects or supports.'), attribute.range);
|
||||
case PromptHeaderAttributes.model:
|
||||
return this.getModelHover(attribute, attribute.range, localize('promptHeader.prompt.model', 'The model to use in this prompt.'), false);
|
||||
case PromptHeaderAttributes.tools:
|
||||
return this.getToolHover(attribute, position, localize('promptHeader.prompt.tools', 'The tools to use in this prompt.'));
|
||||
case PromptHeaderAttributes.agent:
|
||||
case PromptHeaderAttributes.mode:
|
||||
return this.getAgentHover(attribute, position);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ export class PromptValidator {
|
||||
this.validateHeader(promptAST, promptType, report);
|
||||
await this.validateBody(promptAST, promptType, report);
|
||||
await this.validateFileName(promptAST, promptType, report);
|
||||
await this.validateSkillFolderName(promptAST, promptType, report);
|
||||
}
|
||||
|
||||
private async validateFileName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise<void> {
|
||||
@@ -56,6 +57,36 @@ export class PromptValidator {
|
||||
}
|
||||
}
|
||||
|
||||
private async validateSkillFolderName(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise<void> {
|
||||
if (promptType !== PromptsType.skill) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nameAttribute = promptAST.header?.attributes.find(attr => attr.key === PromptHeaderAttributes.name);
|
||||
if (!nameAttribute || nameAttribute.value.type !== 'string') {
|
||||
return;
|
||||
}
|
||||
|
||||
const skillName = nameAttribute.value.value.trim();
|
||||
if (!skillName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract folder name from path (e.g., .github/skills/my-skill/SKILL.md -> my-skill)
|
||||
const pathParts = promptAST.uri.path.split('/');
|
||||
const skillIndex = pathParts.findIndex(part => part === 'SKILL.md');
|
||||
if (skillIndex > 0) {
|
||||
const folderName = pathParts[skillIndex - 1];
|
||||
if (folderName && skillName !== folderName) {
|
||||
report(toMarker(
|
||||
localize('promptValidator.skillNameFolderMismatch', "The skill name '{0}' should match the folder name '{1}'.", skillName, folderName),
|
||||
nameAttribute.value.range,
|
||||
MarkerSeverity.Warning
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async validateBody(promptAST: ParsedPromptFile, promptType: PromptsType, report: (markers: IMarkerData) => void): Promise<void> {
|
||||
const body = promptAST.body;
|
||||
if (!body) {
|
||||
@@ -159,6 +190,10 @@ export class PromptValidator {
|
||||
break;
|
||||
}
|
||||
|
||||
case PromptsType.skill:
|
||||
// Skill-specific validations (currently none beyond name/description)
|
||||
break;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +221,9 @@ export class PromptValidator {
|
||||
case PromptsType.instructions:
|
||||
report(toMarker(localize('promptValidator.unknownAttribute.instructions', "Attribute '{0}' is not supported in instructions files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning));
|
||||
break;
|
||||
case PromptsType.skill:
|
||||
report(toMarker(localize('promptValidator.unknownAttribute.skill', "Attribute '{0}' is not supported in skill files. Supported: {1}.", attribute.key, supportedNames.value), attribute.range, MarkerSeverity.Warning));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -500,7 +538,7 @@ const allAttributeNames = {
|
||||
[PromptsType.prompt]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.mode, PromptHeaderAttributes.agent, PromptHeaderAttributes.argumentHint],
|
||||
[PromptsType.instructions]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.applyTo, PromptHeaderAttributes.excludeAgent],
|
||||
[PromptsType.agent]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.model, PromptHeaderAttributes.tools, PromptHeaderAttributes.advancedOptions, PromptHeaderAttributes.handOffs, PromptHeaderAttributes.argumentHint, PromptHeaderAttributes.target, PromptHeaderAttributes.infer],
|
||||
[PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description],
|
||||
[PromptsType.skill]: [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.license, PromptHeaderAttributes.compatibility, PromptHeaderAttributes.metadata],
|
||||
};
|
||||
const githubCopilotAgentAttributeNames = [PromptHeaderAttributes.name, PromptHeaderAttributes.description, PromptHeaderAttributes.tools, PromptHeaderAttributes.target, GithubPromptHeaderAttributes.mcpServers, PromptHeaderAttributes.infer];
|
||||
const recommendedAttributeNames = {
|
||||
|
||||
@@ -75,6 +75,9 @@ export namespace PromptHeaderAttributes {
|
||||
export const excludeAgent = 'excludeAgent';
|
||||
export const target = 'target';
|
||||
export const infer = 'infer';
|
||||
export const license = 'license';
|
||||
export const compatibility = 'compatibility';
|
||||
export const metadata = 'metadata';
|
||||
}
|
||||
|
||||
export namespace GithubPromptHeaderAttributes {
|
||||
|
||||
@@ -204,6 +204,8 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
} else if (type === PromptsType.prompt) {
|
||||
this.cachedFileLocations[PromptsType.prompt] = undefined;
|
||||
this.cachedSlashCommands.refresh();
|
||||
} else if (type === PromptsType.skill) {
|
||||
this.cachedFileLocations[PromptsType.skill] = undefined;
|
||||
}
|
||||
}));
|
||||
}
|
||||
@@ -217,6 +219,8 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
} else if (type === PromptsType.prompt) {
|
||||
this.cachedFileLocations[PromptsType.prompt] = undefined;
|
||||
this.cachedSlashCommands.refresh();
|
||||
} else if (type === PromptsType.skill) {
|
||||
this.cachedFileLocations[PromptsType.skill] = undefined;
|
||||
}
|
||||
|
||||
disposables.add({
|
||||
@@ -232,6 +236,8 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
} else if (type === PromptsType.prompt) {
|
||||
this.cachedFileLocations[PromptsType.prompt] = undefined;
|
||||
this.cachedSlashCommands.refresh();
|
||||
} else if (type === PromptsType.skill) {
|
||||
this.cachedFileLocations[PromptsType.skill] = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,8 +345,11 @@ export class PromptsService extends Disposable implements IPromptsService {
|
||||
}
|
||||
}
|
||||
|
||||
const userHome = this.userDataService.currentProfile.promptsHome;
|
||||
result.push({ uri: userHome, storage: PromptsStorage.user, type });
|
||||
if (type !== PromptsType.skill) {
|
||||
// no user source folders for skills
|
||||
const userHome = this.userDataService.currentProfile.promptsHome;
|
||||
result.push({ uri: userHome, storage: PromptsStorage.user, type });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -379,4 +379,40 @@ suite('PromptHoverProvider', () => {
|
||||
assert.strictEqual(hover, 'The name of the instruction file as shown in the UI. If not set, the name is derived from the file name.');
|
||||
});
|
||||
});
|
||||
|
||||
suite('skill hovers', () => {
|
||||
test('hover on name attribute', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: "My Skill"',
|
||||
'description: "Test skill"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const hover = await getHover(content, 2, 1, PromptsType.skill);
|
||||
assert.strictEqual(hover, 'The name of the skill.');
|
||||
});
|
||||
|
||||
test('hover on description attribute', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: "Test Skill"',
|
||||
'description: "Test skill description"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const hover = await getHover(content, 3, 1, PromptsType.skill);
|
||||
assert.strictEqual(hover, 'The description of the skill. The description is added to every request and will be used by the agent to decide when to load the skill.');
|
||||
});
|
||||
|
||||
test('hover on file attribute', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: "Test Skill"',
|
||||
'description: "Test skill"',
|
||||
'file: "SKILL.md"',
|
||||
'---',
|
||||
].join('\n');
|
||||
const hover = await getHover(content, 4, 1, PromptsType.skill);
|
||||
assert.strictEqual(hover, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,8 +141,10 @@ suite('PromptValidator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
async function validate(code: string, promptType: PromptsType): Promise<IMarkerData[]> {
|
||||
const uri = URI.parse('myFs://test/testFile' + getPromptFileExtension(promptType));
|
||||
async function validate(code: string, promptType: PromptsType, uri?: URI): Promise<IMarkerData[]> {
|
||||
if (!uri) {
|
||||
uri = URI.parse('myFs://test/testFile' + getPromptFileExtension(promptType));
|
||||
}
|
||||
const result = new PromptFileParser().parse(uri, code);
|
||||
const validator = instaService.createInstance(PromptValidator);
|
||||
const markers: IMarkerData[] = [];
|
||||
@@ -1122,4 +1124,63 @@ suite('PromptValidator', () => {
|
||||
|
||||
});
|
||||
|
||||
suite('skills', () => {
|
||||
|
||||
test('skill name matches folder name', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: my-skill',
|
||||
'description: Test Skill',
|
||||
'---',
|
||||
'This is a skill.'
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md'));
|
||||
assert.deepStrictEqual(markers, [], 'Expected no validation issues when name matches folder');
|
||||
});
|
||||
|
||||
test('skill name does not match folder name', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: different-name',
|
||||
'description: Test Skill',
|
||||
'---',
|
||||
'This is a skill.'
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md'));
|
||||
assert.strictEqual(markers.length, 1);
|
||||
assert.strictEqual(markers[0].severity, MarkerSeverity.Warning);
|
||||
assert.strictEqual(markers[0].message, `The skill name 'different-name' should match the folder name 'my-skill'.`);
|
||||
});
|
||||
|
||||
test('skill without name attribute does not error', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'description: Test Skill',
|
||||
'---',
|
||||
'This is a skill without a name.'
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md'));
|
||||
assert.deepStrictEqual(markers, [], 'Expected no validation issues when name is missing');
|
||||
});
|
||||
|
||||
test('skill with unknown attributes shows warning', async () => {
|
||||
const content = [
|
||||
'---',
|
||||
'name: my-skill',
|
||||
'description: Test Skill',
|
||||
'unknownAttr: value',
|
||||
'anotherUnknown: 123',
|
||||
'---',
|
||||
'This is a skill.'
|
||||
].join('\n');
|
||||
const markers = await validate(content, PromptsType.skill, URI.parse('file:///.github/skills/my-skill/SKILL.md'));
|
||||
assert.strictEqual(markers.length, 2);
|
||||
assert.ok(markers.every(m => m.severity === MarkerSeverity.Warning));
|
||||
assert.ok(markers.some(m => m.message.includes('unknownAttr')));
|
||||
assert.ok(markers.some(m => m.message.includes('anotherUnknown')));
|
||||
assert.ok(markers.every(m => m.message.includes('Supported: ')));
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user