mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
feat: expand repo customization discovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -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 === '**/*';
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user