feat: add phase 3 prompt customization backend

- parse prompt model and argument-hint metadata and persist model on prompt invocations
- expand markdown-linked file context in scanned prompt and instruction bodies
- discover .claude/rules instructions and support Claude-style paths metadata
- discover customization roots up to the nearest parent repository and watch them for changes
- apply per-turn prompt model overrides before sidecar execution

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-04-02 12:21:59 +02:00
co-authored by Copilot
parent bcbdd2ef29
commit 6eadb36c10
17 changed files with 779 additions and 114 deletions
+78 -4
View File
@@ -25,6 +25,7 @@ import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
import {
buildAvailableModelCatalog,
findModel,
findModelByReference,
normalizePatternModels,
resolveReasoningEffort,
} from '@shared/domain/models';
@@ -50,6 +51,7 @@ import {
resolveProjectInstructionsContent,
setProjectAgentProfileEnabled,
type ProjectAgentProfile,
type ProjectPromptFile,
type ProjectPromptInvocation,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
@@ -211,6 +213,40 @@ function buildPromptInvocationFallbackContent(promptInvocation?: ProjectPromptIn
return `Run prompt file: ${promptInvocation.name}`;
}
function hydratePromptInvocationMetadata(
promptInvocation: ProjectPromptInvocation | undefined,
projectCustomization: ProjectCustomizationState | undefined,
): ProjectPromptInvocation | undefined {
if (!promptInvocation) {
return undefined;
}
const matchingPromptFile = findMatchingPromptFile(projectCustomization?.promptFiles, promptInvocation);
if (!matchingPromptFile) {
return promptInvocation;
}
return normalizeProjectPromptInvocation({
...promptInvocation,
description: promptInvocation.description ?? matchingPromptFile.description,
agent: promptInvocation.agent ?? matchingPromptFile.agent,
model: promptInvocation.model ?? matchingPromptFile.model,
tools: promptInvocation.tools ?? matchingPromptFile.tools,
});
}
function findMatchingPromptFile(
promptFiles: ReadonlyArray<ProjectPromptFile> | undefined,
promptInvocation: ProjectPromptInvocation,
): ProjectPromptFile | undefined {
if (!promptFiles || promptFiles.length === 0) {
return undefined;
}
return promptFiles.find((promptFile) => promptFile.id === promptInvocation.id)
?? promptFiles.find((promptFile) => promptFile.sourcePath === promptInvocation.sourcePath);
}
function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean {
return promptInvocation?.agent?.trim().toLowerCase() === 'plan';
}
@@ -1037,7 +1073,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
);
const projectInstructions = resolveProjectInstructionsContent(project.customization);
const normalizedPromptInvocation = normalizeProjectPromptInvocation(promptInvocation);
const normalizedPromptInvocation = hydratePromptInvocationMetadata(
normalizeProjectPromptInvocation(promptInvocation),
project.customization,
);
const preparedContent = prepareChatMessageContent(content)
?? buildPromptInvocationFallbackContent(normalizedPromptInvocation);
if (!preparedContent) {
@@ -1422,6 +1461,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
const patternForTurn = await this.applyPromptInvocationToPattern(effectivePattern, promptInvocation);
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
? 'plan'
: session.interactionMode ?? 'interactive';
@@ -1436,7 +1476,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
}
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
session.title = resolveSessionTitle(session, patternForTurn, session.messages);
session.status = 'running';
session.lastError = undefined;
session.pendingPlanReview = undefined;
@@ -1448,7 +1488,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
project,
workingDirectory: runWorkingDirectory,
workspaceKind,
pattern: effectivePattern,
pattern: patternForTurn,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
@@ -1477,7 +1517,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
mode: interactionMode,
messageMode,
projectInstructions,
pattern: effectivePattern,
pattern: patternForTurn,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
@@ -2889,6 +2929,40 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
}
private async applyPromptInvocationToPattern(
pattern: PatternDefinition,
promptInvocation?: ProjectPromptInvocation,
): Promise<PatternDefinition> {
const requestedModel = promptInvocation?.model?.trim();
if (!requestedModel) {
return pattern;
}
const modelCatalog = await this.loadAvailableModelCatalog();
const resolvedModel = findModelByReference(requestedModel, modelCatalog);
const effectiveModelId = resolvedModel?.id ?? requestedModel;
let didChange = false;
const agents = pattern.agents.map((agent) => {
const reasoningEffort = resolvedModel
? resolveReasoningEffort(resolvedModel, agent.reasoningEffort)
: agent.reasoningEffort;
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
return agent;
}
didChange = true;
return {
...agent,
model: effectiveModelId,
reasoningEffort,
};
});
return didChange ? { ...pattern, agents } : pattern;
}
private applyProjectCustomizationToPattern(
pattern: PatternDefinition,
project: ProjectRecord,
+183 -100
View File
@@ -14,6 +14,8 @@ import {
type ProjectPromptVariable,
} from '@shared/domain/projectCustomization';
import { nowIso } from '@shared/utils/ids';
import { expandMarkdownFileLinks } from '@main/services/projectCustomizationLinkResolver';
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
@@ -23,9 +25,21 @@ export class ProjectCustomizationScanner {
current?: ProjectCustomizationState,
): Promise<ProjectCustomizationState> {
const previous = normalizeProjectCustomizationState(current);
const instructions = await this.scanInstructionFiles(projectPath, previous);
const agentProfiles = await this.scanAgentProfiles(projectPath, previous);
const promptFiles = await this.scanPromptFiles(projectPath, previous);
const customizationRoots = await resolveProjectCustomizationRoots(projectPath);
const allowedRootPath = customizationRoots.at(-1) ?? projectPath;
const instructions = await this.scanInstructionFiles(
projectPath,
customizationRoots,
allowedRootPath,
previous,
);
const agentProfiles = await this.scanAgentProfiles(projectPath, customizationRoots, previous);
const promptFiles = await this.scanPromptFiles(
projectPath,
customizationRoots,
allowedRootPath,
previous,
);
return mergeProjectCustomizationState(
previous,
@@ -40,31 +54,60 @@ export class ProjectCustomizationScanner {
private async scanInstructionFiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
allowedRootPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectInstructionFile[]> {
const previousByPath = new Map(previous.instructions.map((instruction) => [instruction.sourcePath, instruction]));
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 instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, 'always');
if (instruction) {
instructions.push(instruction);
}
}
for (const customizationRoot of customizationRoots) {
const alwaysOnSourcePaths = [
'.github\\copilot-instructions.md',
'AGENTS.md',
'CLAUDE.md',
'.claude\\CLAUDE.md',
] as const;
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);
for (const sourcePath of alwaysOnSourcePaths) {
const filePath = join(customizationRoot, ...sourcePath.split('\\'));
const normalizedSourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, normalizedSourcePath, previousByPath, {
applicationMode: 'always',
projectPath,
allowedRootPath,
});
if (instruction) {
instructions.push(instruction);
}
}
const instructionFilePaths = await this.listProjectFiles(
join(customizationRoot, '.github', 'instructions'),
'.instructions.md',
);
for (const filePath of instructionFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
projectPath,
allowedRootPath,
});
if (instruction) {
instructions.push(instruction);
}
}
const claudeRuleFilePaths = await this.listProjectFiles(join(customizationRoot, '.claude', 'rules'), '.md');
for (const filePath of claudeRuleFilePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const instruction = await this.scanInstructionFile(filePath, sourcePath, previousByPath, {
projectPath,
allowedRootPath,
usesClaudeRulePaths: true,
});
if (instruction) {
instructions.push(instruction);
}
}
}
@@ -73,55 +116,58 @@ export class ProjectCustomizationScanner {
private async scanAgentProfiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
previous: ProjectCustomizationState,
): Promise<ProjectAgentProfile[]> {
const previousByPath = new Map(previous.agentProfiles.map((profile) => [profile.sourcePath, profile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'agents'), '.agent.md');
const profiles: ProjectAgentProfile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
for (const customizationRoot of customizationRoots) {
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'agents'), '.agent.md');
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
if (contents.kind === 'missing') {
continue;
}
continue;
}
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
profiles.push(existing);
}
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
const name = readOptionalString(parsedFile.attributes, ['name'])
?? basename(filePath, '.agent.md');
const prompt = parsedFile.body.trim();
if (!name || !prompt) {
continue;
}
profiles.push({
id: buildProjectCustomizationItemId('agent', sourcePath),
name,
displayName: readOptionalString(parsedFile.attributes, ['displayName', 'display-name']),
description: readOptionalString(parsedFile.attributes, ['description']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
prompt,
mcpServers: readOptionalNamedObjectMap(parsedFile.attributes['mcp-servers']),
infer: typeof parsedFile.attributes.infer === 'boolean' ? parsedFile.attributes.infer : undefined,
sourcePath,
enabled: previousByPath.get(sourcePath)?.enabled ?? true,
});
}
}
return profiles;
@@ -129,51 +175,61 @@ export class ProjectCustomizationScanner {
private async scanPromptFiles(
projectPath: string,
customizationRoots: ReadonlyArray<string>,
allowedRootPath: string,
previous: ProjectCustomizationState,
): Promise<ProjectPromptFile[]> {
const previousByPath = new Map(previous.promptFiles.map((promptFile) => [promptFile.sourcePath, promptFile]));
const filePaths = await this.listProjectFiles(join(projectPath, '.github', 'prompts'), '.prompt.md');
const promptFiles: ProjectPromptFile[] = [];
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
for (const customizationRoot of customizationRoots) {
const filePaths = await this.listProjectFiles(join(customizationRoot, '.github', 'prompts'), '.prompt.md');
for (const filePath of filePaths) {
const sourcePath = toProjectSourcePath(projectPath, filePath);
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'retain-previous') {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
continue;
}
if (contents.kind === 'missing') {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
if (contents.kind === 'missing') {
continue;
}
continue;
}
const template = parsedFile.body.trim();
if (!template) {
continue;
}
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
if (!parsedFile) {
const existing = previousByPath.get(sourcePath);
if (existing) {
promptFiles.push(existing);
}
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
template,
variables: extractPromptVariables(template),
sourcePath,
});
const template = await expandMarkdownFileLinks(parsedFile.body, {
sourceFilePath: filePath,
projectPath,
allowedRootPath,
});
if (!template) {
continue;
}
promptFiles.push({
id: buildProjectCustomizationItemId('prompt', sourcePath),
name: readOptionalString(parsedFile.attributes, ['name']) ?? basename(filePath, '.prompt.md'),
description: readOptionalString(parsedFile.attributes, ['description']),
argumentHint: readOptionalString(parsedFile.attributes, ['argument-hint', 'argumentHint']),
agent: readOptionalString(parsedFile.attributes, ['agent']),
model: readOptionalString(parsedFile.attributes, ['model']),
tools: readOptionalStringArray(parsedFile.attributes.tools),
template,
variables: extractPromptVariables(template),
sourcePath,
});
}
}
return promptFiles;
@@ -232,7 +288,12 @@ export class ProjectCustomizationScanner {
filePath: string,
sourcePath: string,
previousByPath: ReadonlyMap<string, ProjectInstructionFile>,
applicationMode?: ProjectInstructionApplicationMode,
options: {
applicationMode?: ProjectInstructionApplicationMode;
projectPath: string;
allowedRootPath: string;
usesClaudeRulePaths?: boolean;
},
): Promise<ProjectInstructionFile | undefined> {
const contents = await this.readProjectFile(filePath);
if (contents.kind === 'missing') {
@@ -248,18 +309,22 @@ export class ProjectCustomizationScanner {
return previousByPath.get(sourcePath);
}
const content = parsedFile.body.trim();
const content = await expandMarkdownFileLinks(parsedFile.body, {
sourceFilePath: filePath,
projectPath: options.projectPath,
allowedRootPath: options.allowedRootPath,
});
if (!content) {
return undefined;
}
const description = readOptionalString(parsedFile.attributes, ['description']);
const applyTo = readOptionalString(parsedFile.attributes, ['applyTo']);
const applyTo = readInstructionApplyTo(parsedFile.attributes, options.usesClaudeRulePaths === true);
const instruction: ProjectInstructionFile = {
id: buildProjectCustomizationItemId('instruction', sourcePath),
sourcePath,
content,
applicationMode: applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
applicationMode: options.applicationMode ?? resolveInstructionApplicationMode(applyTo, description),
};
const name = readOptionalString(parsedFile.attributes, ['name']);
@@ -368,6 +433,24 @@ function readOptionalString(
return undefined;
}
function readInstructionApplyTo(
record: Record<string, unknown>,
usesClaudeRulePaths: boolean,
): string | undefined {
const applyTo = readOptionalString(record, ['applyTo']);
const paths = readOptionalStringArray(record.paths);
if (paths && paths.length > 0) {
return paths.join(',');
}
if (applyTo) {
return applyTo;
}
return usesClaudeRulePaths ? '**' : undefined;
}
function resolveInstructionApplicationMode(
applyTo: string | undefined,
description: string | undefined,
@@ -0,0 +1,202 @@
import { readFile } from 'node:fs/promises';
import { basename, dirname, isAbsolute, relative, resolve } from 'node:path';
const markdownLinkPattern = /\[[^\]]+\]\(([^)]+)\)/g;
type MarkdownLinkResolutionContext = {
projectPath: string;
allowedRootPath: string;
sourceFilePath: string;
seenPaths: Set<string>;
ancestry: readonly string[];
};
export async function expandMarkdownFileLinks(
content: string,
options: {
projectPath: string;
allowedRootPath: string;
sourceFilePath: string;
},
): Promise<string> {
const trimmedContent = content.trim();
if (!trimmedContent) {
return trimmedContent;
}
return expandMarkdownFileLinksRecursive(trimmedContent, {
...options,
seenPaths: new Set<string>(),
ancestry: [options.sourceFilePath],
});
}
async function expandMarkdownFileLinksRecursive(
content: string,
context: MarkdownLinkResolutionContext,
): Promise<string> {
const referencedBlocks: string[] = [];
for (const linkTarget of collectLocalMarkdownLinkTargets(content)) {
const resolvedPath = resolveMarkdownLinkTarget(context.sourceFilePath, linkTarget);
if (!resolvedPath) {
continue;
}
if (!isPathInsideRoot(resolvedPath, context.allowedRootPath)) {
console.warn(
`[aryx customization] Ignoring linked file outside the allowed customization root: ${resolvedPath}`,
);
continue;
}
if (context.seenPaths.has(resolvedPath)) {
continue;
}
if (context.ancestry.includes(resolvedPath)) {
console.warn(`[aryx customization] Ignoring circular Markdown link reference to ${resolvedPath}.`);
continue;
}
const linkedContent = await readLinkedFile(resolvedPath);
if (linkedContent === undefined) {
continue;
}
context.seenPaths.add(resolvedPath);
const expandedLinkedContent = isMarkdownLikePath(resolvedPath)
? await expandMarkdownFileLinksRecursive(linkedContent, {
...context,
sourceFilePath: resolvedPath,
ancestry: [...context.ancestry, resolvedPath],
})
: linkedContent.trim();
referencedBlocks.push(
formatReferencedFileBlock(
toProjectSourcePath(context.projectPath, resolvedPath),
expandedLinkedContent,
),
);
}
if (referencedBlocks.length === 0) {
return content.trim();
}
return `${content.trim()}\n\nReferenced file context:\n\n${referencedBlocks.join('\n\n')}`.trim();
}
function collectLocalMarkdownLinkTargets(content: string): string[] {
const targets: string[] = [];
const seenTargets = new Set<string>();
let match: RegExpExecArray | null;
while ((match = markdownLinkPattern.exec(content))) {
const target = match[1]?.trim();
if (!target || seenTargets.has(target)) {
continue;
}
seenTargets.add(target);
targets.push(target);
}
markdownLinkPattern.lastIndex = 0;
return targets;
}
function resolveMarkdownLinkTarget(sourceFilePath: string, rawTarget: string): string | undefined {
const target = extractMarkdownLinkDestination(rawTarget);
if (!target) {
return undefined;
}
return resolve(dirname(sourceFilePath), target);
}
function extractMarkdownLinkDestination(rawTarget: string): string | undefined {
let target = rawTarget.trim();
if (!target) {
return undefined;
}
if (target.startsWith('<') && target.endsWith('>')) {
target = target.slice(1, -1).trim();
} else {
const whitespaceIndex = target.search(/\s/);
if (whitespaceIndex >= 0) {
target = target.slice(0, whitespaceIndex);
}
}
const hashIndex = target.indexOf('#');
if (hashIndex >= 0) {
target = target.slice(0, hashIndex);
}
if (!target) {
return undefined;
}
const normalizedTarget = target.toLowerCase();
if (
target.startsWith('#')
|| isAbsolute(target)
|| normalizedTarget.startsWith('http://')
|| normalizedTarget.startsWith('https://')
|| normalizedTarget.startsWith('mailto:')
|| normalizedTarget.startsWith('vscode:')
|| normalizedTarget.startsWith('command:')
|| normalizedTarget.startsWith('data:')
) {
return undefined;
}
return target;
}
async function readLinkedFile(filePath: string): Promise<string | undefined> {
try {
const content = await readFile(filePath, 'utf8');
if (content.includes('\0')) {
console.warn(`[aryx customization] Ignoring binary-linked file ${filePath}.`);
return undefined;
}
return content.trim();
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
console.warn(`[aryx customization] Linked file not found: ${filePath}`);
return undefined;
}
console.warn(`[aryx customization] Failed to read linked file ${filePath}:`, error);
return undefined;
}
}
function formatReferencedFileBlock(sourcePath: string, content: string): string {
return [
`Source: ${sourcePath}`,
'Contents:',
content.trim() || '[empty file]',
].join('\n');
}
function isMarkdownLikePath(filePath: string): boolean {
const normalizedPath = filePath.toLowerCase();
return normalizedPath.endsWith('.md') || normalizedPath.endsWith('.markdown');
}
function isPathInsideRoot(filePath: string, rootPath: string): boolean {
const relativePath = relative(rootPath, filePath);
return relativePath.length === 0
|| (!relativePath.startsWith('..') && !isAbsolute(relativePath));
}
function toProjectSourcePath(projectPath: string, filePath: string): string {
const relativePath = relative(projectPath, filePath).trim();
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
}
@@ -0,0 +1,38 @@
import { access } from 'node:fs/promises';
import { dirname, join } from 'node:path';
export async function resolveProjectCustomizationRoots(projectPath: string): Promise<string[]> {
if (await hasGitEntry(projectPath)) {
return [projectPath];
}
const ancestorPaths: string[] = [];
let currentPath = projectPath;
while (true) {
const parentPath = dirname(currentPath);
if (parentPath === currentPath) {
return [projectPath];
}
ancestorPaths.push(parentPath);
if (await hasGitEntry(parentPath)) {
return [projectPath, ...ancestorPaths];
}
currentPath = parentPath;
}
}
async function hasGitEntry(directoryPath: string): Promise<boolean> {
try {
await access(join(directoryPath, '.git'));
return true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return false;
}
console.warn(`[aryx customization] Failed to inspect ${join(directoryPath, '.git')}:`, error);
return false;
}
}
@@ -2,6 +2,8 @@ import { watch } from 'node:fs';
import { readdir } from 'node:fs/promises';
import { join } from 'node:path';
import { resolveProjectCustomizationRoots } from '@main/services/projectCustomizationRoots';
export interface ProjectCustomizationWatchTarget {
id: string;
path: string;
@@ -128,11 +130,15 @@ export class ProjectCustomizationWatcher {
}
export async function collectProjectCustomizationWatchPaths(projectPath: string): Promise<string[]> {
const paths = new Set<string>([projectPath]);
const paths = new Set<string>();
for (const relativeRoot of ['.github', '.claude']) {
for (const directoryPath of await collectExistingDirectories(join(projectPath, relativeRoot))) {
paths.add(directoryPath);
for (const customizationRoot of await resolveProjectCustomizationRoots(projectPath)) {
paths.add(customizationRoot);
for (const relativeRoot of ['.github', '.claude']) {
for (const directoryPath of await collectExistingDirectories(join(customizationRoot, relativeRoot))) {
paths.add(directoryPath);
}
}
}
+14
View File
@@ -178,6 +178,20 @@ export function findModel(
return models.find((model) => model.id === id);
}
export function findModelByReference(
reference: string,
models: ReadonlyArray<ModelDefinition> = modelCatalog,
): ModelDefinition | undefined {
const trimmedReference = reference.trim();
if (!trimmedReference) {
return undefined;
}
return models.find((model) =>
model.id === trimmedReference
|| model.name.localeCompare(trimmedReference, undefined, { sensitivity: 'accent' }) === 0);
}
export function inferProvider(modelId: string): ModelProvider | undefined {
if (modelId.startsWith('gpt-')) return 'openai';
if (modelId.startsWith('claude-')) return 'anthropic';
+18
View File
@@ -36,7 +36,9 @@ export interface ProjectPromptFile {
id: string;
name: string;
description?: string;
argumentHint?: string;
agent?: string;
model?: string;
tools?: string[];
template: string;
variables: ProjectPromptVariable[];
@@ -50,6 +52,7 @@ export interface ProjectPromptInvocation {
resolvedPrompt: string;
description?: string;
agent?: string;
model?: string;
tools?: string[];
}
@@ -268,11 +271,21 @@ function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromp
normalizedPromptFile.description = description;
}
const argumentHint = normalizeOptionalString(promptFile.argumentHint);
if (argumentHint) {
normalizedPromptFile.argumentHint = argumentHint;
}
const agent = normalizeOptionalString(promptFile.agent);
if (agent) {
normalizedPromptFile.agent = agent;
}
const model = normalizeOptionalString(promptFile.model);
if (model) {
normalizedPromptFile.model = model;
}
const tools = normalizeOptionalStringArray(promptFile.tools);
if (tools) {
normalizedPromptFile.tools = tools;
@@ -313,6 +326,11 @@ export function normalizeProjectPromptInvocation(
normalizedPromptInvocation.agent = agent;
}
const model = normalizeOptionalString(promptInvocation.model);
if (model) {
normalizedPromptInvocation.model = model;
}
const tools = normalizeOptionalStringArray(promptInvocation.tools);
if (tools) {
normalizedPromptInvocation.tools = tools;