feat: expand repo customization discovery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 11:14:39 +02:00
co-authored by Copilot
parent dc69f8bf04
commit 36b37e8915
6 changed files with 420 additions and 44 deletions
+114 -28
View File
@@ -7,6 +7,7 @@ import {
mergeProjectCustomizationState,
normalizeProjectCustomizationState,
type ProjectAgentProfile,
type ProjectInstructionApplicationMode,
type ProjectCustomizationState,
type ProjectInstructionFile,
type ProjectPromptFile,
@@ -42,34 +43,29 @@ export class ProjectCustomizationScanner {
previous: ProjectCustomizationState,
): Promise<ProjectInstructionFile[]> {
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
const sourcePaths = ['.github\\copilot-instructions.md', 'AGENTS.md'] as const;
const sourcePaths = [
'.github\\copilot-instructions.md',
'AGENTS.md',
'CLAUDE.md',
'.claude\\CLAUDE.md',
] as const;
const instructions: ProjectInstructionFile[] = [];
for (const sourcePath of sourcePaths) {
const filePath = join(projectPath, ...sourcePath.split('\\'));
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
continue;
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, 'always');
if (instruction) {
instructions.push(instruction);
}
}
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
instructions.push(existing);
}
continue;
const instructionFilePaths = await this.listProjectFiles(join(projectPath, '.github', 'instructions'), '.instructions.md');
for (const filePath of instructionFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath);
if (instruction) {
instructions.push(instruction);
}
const content = contents.value.trim();
if (!content) {
continue;
}
instructions.push({
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
});
}
return instructions;
@@ -170,7 +166,7 @@ export class ProjectCustomizationScanner {
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: basename(filePath, '.prompt.md'),
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
template,
@@ -183,19 +179,31 @@ export class ProjectCustomizationScanner {
}
private async listProjectFiles(directoryPath: string, suffix: string): Promise<string[]> {
const filePaths: string[] = [];
await this.collectProjectFiles(directoryPath, suffix, filePaths);
return filePaths.sort((left, right) => left.localeCompare(right));
}
private async collectProjectFiles(directoryPath: string, suffix: string, filePaths: string[]): Promise<void> {
try {
const entries = await readdir(directoryPath, { withFileTypes: true });
return entries
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(suffix))
.map((entry) => join(directoryPath, entry.name))
.sort((left, right) => left.localeCompare(right));
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
const entryPath = join(directoryPath, entry.name);
if (entry.isDirectory()) {
await this.collectProjectFiles(entryPath, suffix, filePaths);
continue;
}
if (entry.isFile() && entry.name.toLowerCase().endsWith(suffix)) {
filePaths.push(entryPath);
}
}
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
return;
}
console.warn(`[aryx customization] Failed to read directory ${directoryPath}:`, error);
return [];
}
}
@@ -218,6 +226,56 @@ export class ProjectCustomizationScanner {
return { kind: 'retain-previous' };
}
}
private async scanInstructionFile(
filePath: string,
sourcePath: string,
previousByPath: ReadonlyMap<string, ProjectInstructionFile>,
applicationMode?: ProjectInstructionApplicationMode,
): Promise<ProjectInstructionFile | undefined> {
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
return undefined;
}
if (contents.kind === 'retain-previous') {
return previousByPath.get(sourcePath);
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
return previousByPath.get(sourcePath);
}
const content = parsedFile.body.trim();
if (!content) {
return undefined;
}
const description = readOptionalString(parsedFile.attributes, ['description']);
const applyTo = readOptionalString(parsedFile.attributes, ['applyTo']);
const instruction: ProjectInstructionFile = {
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
applicationMode: applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
};
const name = readOptionalString(parsedFile.attributes, ['name']);
if (name) {
instruction.name = name;
}
if (description) {
instruction.description = description;
}
if (applyTo) {
instruction.applyTo = applyTo;
}
return instruction;
}
}
function parseProjectFrontmatter(
@@ -309,6 +367,25 @@ function readOptionalString(
return undefined;
}
function resolveInstructionApplicationMode(
applyTo: string | undefined,
description: string | undefined,
): ProjectInstructionApplicationMode {
if (isMatchAllInstructionGlob(applyTo)) {
return 'always';
}
if (applyTo) {
return 'file';
}
if (description) {
return 'task';
}
return 'manual';
}
function readOptionalStringArray(value: unknown): string[] | undefined {
if (value === undefined) {
return undefined;
@@ -368,3 +445,12 @@ function normalizeYamlValue(value: unknown): unknown {
function isPlainObject(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function isMatchAllInstructionGlob(value: string | undefined): boolean {
if (!value) {
return false;
}
const normalizedValue = value.trim().replaceAll('\\', '/');
return normalizedValue === '**' || normalizedValue === '**/*';
}
+142 -4
View File
@@ -1,7 +1,13 @@
export type ProjectInstructionApplicationMode = 'always' | 'file' | 'task' | 'manual';
export interface ProjectInstructionFile {
id: string;
sourcePath: string;
content: string;
name?: string;
description?: string;
applyTo?: string;
applicationMode: ProjectInstructionApplicationMode;
}
export interface ProjectAgentProfileMcpServerConfig {
@@ -111,8 +117,34 @@ export function listEnabledProjectAgentProfiles(
export function resolveProjectInstructionsContent(
state?: Partial<ProjectCustomizationState>,
): string | undefined {
const content = normalizeProjectCustomizationState(state).instructions
.map((instruction) => instruction.content)
const instructions = normalizeProjectCustomizationState(state).instructions;
const contentBlocks = instructions
.filter((instruction) => instruction.applicationMode === 'always')
.map((instruction) => instruction.content);
const fileScopedInstructions = instructions.filter((instruction) => instruction.applicationMode === 'file');
if (fileScopedInstructions.length > 0) {
contentBlocks.push(
formatProjectInstructionSection(
'Repository file-scoped instructions:',
'Apply each instruction only when working on files whose relative workspace path matches the listed glob.',
fileScopedInstructions,
),
);
}
const taskScopedInstructions = instructions.filter((instruction) => instruction.applicationMode === 'task');
if (taskScopedInstructions.length > 0) {
contentBlocks.push(
formatProjectInstructionSection(
'Repository task-scoped instructions:',
'Apply each instruction only when the current task matches its description.',
taskScopedInstructions,
),
);
}
const content = contentBlocks
.filter((value) => value.length > 0)
.join('\n\n')
.trim();
@@ -139,11 +171,35 @@ export function setProjectAgentProfileEnabled(
}
function normalizeProjectInstructionFile(file: ProjectInstructionFile): ProjectInstructionFile {
return {
const normalizedSourcePath = normalizePathLikeString(file.sourcePath);
const normalizedName = normalizeOptionalString(file.name);
const normalizedDescription = normalizeOptionalString(file.description);
const normalizedApplyTo = normalizeOptionalString(file.applyTo);
const normalizedInstruction: ProjectInstructionFile = {
id: file.id.trim(),
sourcePath: normalizePathLikeString(file.sourcePath),
sourcePath: normalizedSourcePath,
content: file.content.trim(),
applicationMode: normalizeInstructionApplicationMode(
file.applicationMode,
normalizedSourcePath,
normalizedApplyTo,
normalizedDescription,
),
};
if (normalizedName) {
normalizedInstruction.name = normalizedName;
}
if (normalizedDescription) {
normalizedInstruction.description = normalizedDescription;
}
if (normalizedApplyTo) {
normalizedInstruction.applyTo = normalizedApplyTo;
}
return normalizedInstruction;
}
function normalizeProjectAgentProfile(profile: ProjectAgentProfile): ProjectAgentProfile {
@@ -216,6 +272,38 @@ function compareProjectFiles(
return left.sourcePath.localeCompare(right.sourcePath) || left.id.localeCompare(right.id);
}
function formatProjectInstructionSection(
title: string,
guidance: string,
instructions: ReadonlyArray<ProjectInstructionFile>,
): string {
return [`${title}\n${guidance}`, ...instructions.map(formatProjectInstructionEntry)]
.filter((block) => block.length > 0)
.join('\n\n')
.trim();
}
function formatProjectInstructionEntry(instruction: ProjectInstructionFile): string {
const lines = [`Source: ${instruction.sourcePath}`];
if (instruction.name) {
lines.push(`Name: ${instruction.name}`);
}
if (instruction.description) {
lines.push(`Description: ${instruction.description}`);
}
if (instruction.applyTo) {
lines.push(`ApplyTo: ${instruction.applyTo}`);
}
lines.push('Instructions:');
lines.push(instruction.content);
return lines.join('\n');
}
function normalizeOptionalMcpServers(
value?: Record<string, ProjectAgentProfileMcpServerConfig>,
): Record<string, ProjectAgentProfileMcpServerConfig> | undefined {
@@ -242,6 +330,19 @@ function normalizeOptionalString(value?: string): string | undefined {
return trimmed ? trimmed : undefined;
}
function normalizeInstructionApplicationMode(
value: ProjectInstructionApplicationMode | undefined,
sourcePath: string,
applyTo?: string,
description?: string,
): ProjectInstructionApplicationMode {
if (value === 'always' || value === 'file' || value === 'task' || value === 'manual') {
return value;
}
return inferInstructionApplicationMode(sourcePath, applyTo, description);
}
function normalizeOptionalStringArray(values?: ReadonlyArray<string>): string[] | undefined {
if (!values) {
return undefined;
@@ -254,6 +355,43 @@ function normalizePathLikeString(value: string): string {
return value.trim().replaceAll('/', '\\');
}
function inferInstructionApplicationMode(
sourcePath: string,
applyTo?: string,
description?: string,
): ProjectInstructionApplicationMode {
if (isAlwaysOnInstructionSource(sourcePath) || isMatchAllInstructionGlob(applyTo)) {
return 'always';
}
if (applyTo) {
return 'file';
}
if (description) {
return 'task';
}
return 'manual';
}
function isAlwaysOnInstructionSource(sourcePath: string): boolean {
const normalizedSourcePath = normalizePathLikeString(sourcePath).toLowerCase();
return normalizedSourcePath === '.github\\copilot-instructions.md'
|| normalizedSourcePath === 'agents.md'
|| normalizedSourcePath === 'claude.md'
|| normalizedSourcePath === '.claude\\claude.md';
}
function isMatchAllInstructionGlob(value?: string): boolean {
if (!value) {
return false;
}
const normalizedValue = value.trim().replaceAll('\\', '/');
return normalizedValue === '**' || normalizedValue === '**/*';
}
function normalizeYamlValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map(normalizeYamlValue);