mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 04:08:45 +02:00
feat: support project copilot customization
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+176
-5
@@ -9,6 +9,7 @@ import type {
|
||||
ApprovalRequestedEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
RunTurnCustomAgentConfig,
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
TurnDeltaEvent,
|
||||
@@ -34,6 +35,14 @@ import {
|
||||
type DiscoveredToolingState,
|
||||
type DiscoveredToolingStatus,
|
||||
} from '@shared/domain/discoveredTooling';
|
||||
import {
|
||||
listEnabledProjectAgentProfiles,
|
||||
normalizeProjectCustomizationState,
|
||||
resolveProjectInstructionsContent,
|
||||
setProjectAgentProfileEnabled,
|
||||
type ProjectAgentProfile,
|
||||
type ProjectCustomizationState,
|
||||
} from '@shared/domain/projectCustomization';
|
||||
import {
|
||||
approvalPolicyRequiresCheckpoint,
|
||||
dequeuePendingApprovalState,
|
||||
@@ -104,6 +113,7 @@ import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||
import { getScratchpadSessionPath } from '@main/persistence/appPaths';
|
||||
import { SecretStore } from '@main/secrets/secretStore';
|
||||
import { ConfigScannerRegistry } from '@main/services/configScanner';
|
||||
import { ProjectCustomizationScanner } from '@main/services/customizationScanner';
|
||||
import {
|
||||
SIDECAR_STOPPED_BEFORE_COMPLETION_MESSAGE,
|
||||
SidecarClient,
|
||||
@@ -167,6 +177,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly secretStore = new SecretStore();
|
||||
private readonly gitService = new GitService();
|
||||
private readonly configScanner = new ConfigScannerRegistry();
|
||||
private readonly customizationScanner = new ProjectCustomizationScanner();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
private workspace?: WorkspaceState;
|
||||
@@ -193,11 +204,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const didSyncProjectTooling = selectedProject
|
||||
? await this.syncProjectDiscoveredTooling(this.workspace, selectedProject)
|
||||
: false;
|
||||
const didSyncProjectCustomization = selectedProject
|
||||
? await this.syncProjectCustomization(selectedProject)
|
||||
: false;
|
||||
const didPruneSelections = this.pruneUnavailableSessionToolingSelections(this.workspace);
|
||||
const didPruneApprovalTools = await this.pruneUnavailableApprovalTools(this.workspace);
|
||||
if (
|
||||
didSyncUserTooling
|
||||
|| didSyncProjectTooling
|
||||
|| didSyncProjectCustomization
|
||||
|| didPruneSelections
|
||||
|| didPruneApprovalTools
|
||||
|| this.cleanupInterruptedSessions(this.workspace)
|
||||
@@ -262,6 +277,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
if (existing) {
|
||||
workspace.selectedProjectId = existing.id;
|
||||
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, existing);
|
||||
await this.syncProjectCustomization(existing);
|
||||
if (didSyncProjectTooling) {
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
@@ -280,6 +296,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspace.projects.push(project);
|
||||
workspace.selectedProjectId = project.id;
|
||||
await this.syncProjectDiscoveredTooling(workspace, project);
|
||||
await this.syncProjectCustomization(project);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
@@ -331,6 +348,28 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async rescanProjectCustomization(projectId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
await this.syncProjectCustomization(project);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async setProjectAgentProfileEnabled(
|
||||
projectId: string,
|
||||
agentProfileId: string,
|
||||
enabled: boolean,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
project.customization = setProjectAgentProfileEnabled(
|
||||
project.customization,
|
||||
agentProfileId,
|
||||
enabled,
|
||||
);
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async resolveProjectDiscoveredTooling(
|
||||
projectId: string,
|
||||
serverIds: string[],
|
||||
@@ -624,7 +663,11 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const pattern = this.requirePattern(workspace, session.patternId);
|
||||
const effectivePattern = await this.buildEffectivePattern(pattern, session);
|
||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
||||
await this.buildEffectivePattern(pattern, session),
|
||||
project,
|
||||
);
|
||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||
|
||||
const preparedContent = prepareChatMessageContent(content);
|
||||
if (!preparedContent) {
|
||||
@@ -679,6 +722,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
projectInstructions,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
@@ -1155,13 +1199,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
? [this.requireProject(workspace, projectId)]
|
||||
: workspace.projects;
|
||||
|
||||
let changed = false;
|
||||
let didRefreshGit = false;
|
||||
let didSyncProjectTooling = false;
|
||||
let didSyncProjectCustomization = false;
|
||||
for (const project of projects) {
|
||||
const projectChanged = await this.refreshGitContextForProject(project);
|
||||
changed = projectChanged || changed;
|
||||
didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit;
|
||||
didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling;
|
||||
didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization;
|
||||
}
|
||||
|
||||
return changed ? this.persistAndBroadcast(workspace) : workspace;
|
||||
const didPruneSelections = didSyncProjectTooling
|
||||
? this.pruneUnavailableSessionToolingSelections(workspace)
|
||||
: false;
|
||||
const didPruneApprovalTools = didSyncProjectTooling
|
||||
? await this.pruneUnavailableApprovalTools(workspace)
|
||||
: false;
|
||||
|
||||
return (
|
||||
didRefreshGit
|
||||
|| didSyncProjectTooling
|
||||
|| didSyncProjectCustomization
|
||||
|| didPruneSelections
|
||||
|| didPruneApprovalTools
|
||||
)
|
||||
? this.persistAndBroadcast(workspace)
|
||||
: workspace;
|
||||
}
|
||||
|
||||
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
||||
@@ -1169,6 +1231,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
if (projectId) {
|
||||
const project = this.requireProject(workspace, projectId);
|
||||
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project);
|
||||
await this.syncProjectCustomization(project);
|
||||
if (didSyncProjectTooling) {
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
@@ -1193,6 +1256,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
const didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project);
|
||||
await this.syncProjectCustomization(project);
|
||||
if (didSyncProjectTooling) {
|
||||
this.pruneUnavailableSessionToolingSelections(workspace);
|
||||
await this.pruneUnavailableApprovalTools(workspace);
|
||||
@@ -1842,6 +1906,77 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
|
||||
}
|
||||
|
||||
private applyProjectCustomizationToPattern(
|
||||
pattern: PatternDefinition,
|
||||
project: ProjectRecord,
|
||||
): PatternDefinition {
|
||||
if (isScratchpadProject(project)) {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
|
||||
if (projectCustomAgents.length === 0) {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const [primaryAgent, ...remainingAgents] = pattern.agents;
|
||||
if (!primaryAgent) {
|
||||
return pattern;
|
||||
}
|
||||
|
||||
const existingCustomAgents = primaryAgent.copilot?.customAgents ?? [];
|
||||
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
|
||||
const mergedCustomAgents = [
|
||||
...existingCustomAgents,
|
||||
...projectCustomAgents.filter((agent) => !existingAgentNames.has(agent.name.toLowerCase())),
|
||||
];
|
||||
|
||||
return {
|
||||
...pattern,
|
||||
agents: [
|
||||
{
|
||||
...primaryAgent,
|
||||
copilot: {
|
||||
...primaryAgent.copilot,
|
||||
customAgents: mergedCustomAgents,
|
||||
},
|
||||
},
|
||||
...remainingAgents,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildProjectCustomAgents(
|
||||
customization?: ProjectCustomizationState,
|
||||
): RunTurnCustomAgentConfig[] {
|
||||
return listEnabledProjectAgentProfiles(customization).map((profile) => this.mapProjectAgentProfile(profile));
|
||||
}
|
||||
|
||||
private mapProjectAgentProfile(profile: ProjectAgentProfile): RunTurnCustomAgentConfig {
|
||||
const customAgent: RunTurnCustomAgentConfig = {
|
||||
name: profile.name,
|
||||
prompt: profile.prompt,
|
||||
};
|
||||
|
||||
if (profile.displayName) {
|
||||
customAgent.displayName = profile.displayName;
|
||||
}
|
||||
|
||||
if (profile.description) {
|
||||
customAgent.description = profile.description;
|
||||
}
|
||||
|
||||
if (profile.tools) {
|
||||
customAgent.tools = profile.tools;
|
||||
}
|
||||
|
||||
if (profile.infer !== undefined) {
|
||||
customAgent.infer = profile.infer;
|
||||
}
|
||||
|
||||
return customAgent;
|
||||
}
|
||||
|
||||
private async listKnownApprovalToolNames(
|
||||
workspace: WorkspaceState,
|
||||
project?: ProjectRecord,
|
||||
@@ -1920,6 +2055,25 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
private async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
|
||||
if (isScratchpadProject(project)) {
|
||||
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.customization = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
|
||||
if (this.equalProjectCustomizationState(project.customization, nextState)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
project.customization = nextState;
|
||||
return true;
|
||||
}
|
||||
|
||||
private async syncProjectDiscoveredTooling(
|
||||
workspace: WorkspaceState,
|
||||
project: ProjectRecord,
|
||||
@@ -1988,6 +2142,23 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
=== JSON.stringify(normalizeDiscoveredToolingState(right).mcpServers);
|
||||
}
|
||||
|
||||
private equalProjectCustomizationState(
|
||||
left?: ProjectCustomizationState,
|
||||
right?: ProjectCustomizationState,
|
||||
): boolean {
|
||||
const normalizedLeft = normalizeProjectCustomizationState(left);
|
||||
const normalizedRight = normalizeProjectCustomizationState(right);
|
||||
return JSON.stringify({
|
||||
instructions: normalizedLeft.instructions,
|
||||
agentProfiles: normalizedLeft.agentProfiles,
|
||||
promptFiles: normalizedLeft.promptFiles,
|
||||
}) === JSON.stringify({
|
||||
instructions: normalizedRight.instructions,
|
||||
agentProfiles: normalizedRight.agentProfiles,
|
||||
promptFiles: normalizedRight.promptFiles,
|
||||
});
|
||||
}
|
||||
|
||||
private updateSessionRun(
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
|
||||
@@ -5,28 +5,30 @@ import { ipcChannels } from '@shared/contracts/channels';
|
||||
import type {
|
||||
CancelSessionTurnInput,
|
||||
CreateSessionInput,
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
DeleteSessionInput,
|
||||
StartSessionMcpAuthInput,
|
||||
DuplicateSessionInput,
|
||||
RenameSessionInput,
|
||||
RescanProjectConfigsInput,
|
||||
RescanProjectCustomizationInput,
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveSessionApprovalInput,
|
||||
ResolveSessionUserInputInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
SendSessionMessageInput,
|
||||
SetPatternFavoriteInput,
|
||||
SetProjectAgentProfileEnabledInput,
|
||||
SetSessionArchivedInput,
|
||||
SetSessionInteractionModeInput,
|
||||
SetSessionPinnedInput,
|
||||
UpdateSessionModelConfigInput,
|
||||
UpdateSessionApprovalSettingsInput,
|
||||
UpdateSessionToolingInput,
|
||||
UpdateSessionModelConfigInput,
|
||||
DeleteSessionInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
@@ -53,11 +55,21 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
|
||||
service.rescanProjectConfigs(input.projectId),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.rescanProjectCustomization,
|
||||
(_event, input: RescanProjectCustomizationInput) =>
|
||||
service.rescanProjectCustomization(input.projectId),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.resolveProjectDiscoveredTooling,
|
||||
(_event, input: ResolveProjectDiscoveredToolingInput) =>
|
||||
service.resolveProjectDiscoveredTooling(input.projectId, input.serverIds, input.resolution),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setProjectAgentProfileEnabled,
|
||||
(_event, input: SetProjectAgentProfileEnabledInput) =>
|
||||
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/patte
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
@@ -73,6 +74,7 @@ export class WorkspaceRepository {
|
||||
(stored.projects ?? []).map((project) => ({
|
||||
...project,
|
||||
discoveredTooling: normalizeDiscoveredToolingState(project.discoveredTooling),
|
||||
customization: normalizeProjectCustomizationState(project.customization),
|
||||
})),
|
||||
this.scratchpadPath,
|
||||
);
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { basename, join, relative } from 'node:path';
|
||||
|
||||
import { parse as parseYaml } from 'yaml';
|
||||
|
||||
import {
|
||||
mergeProjectCustomizationState,
|
||||
normalizeProjectCustomizationState,
|
||||
type ProjectAgentProfile,
|
||||
type ProjectCustomizationState,
|
||||
type ProjectInstructionFile,
|
||||
type ProjectPromptFile,
|
||||
type ProjectPromptVariable,
|
||||
} from '@shared/domain/projectCustomization';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
const promptVariablePattern = /\$\{input:([a-zA-Z0-9_-]+):([^}]+)\}/g;
|
||||
|
||||
export class ProjectCustomizationScanner {
|
||||
async scanProject(
|
||||
projectPath: string,
|
||||
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);
|
||||
|
||||
return mergeProjectCustomizationState(
|
||||
previous,
|
||||
{
|
||||
instructions,
|
||||
agentProfiles,
|
||||
promptFiles,
|
||||
},
|
||||
nowIso(),
|
||||
);
|
||||
}
|
||||
|
||||
private async scanInstructionFiles(
|
||||
projectPath: string,
|
||||
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 instructions: ProjectInstructionFile[] = [];
|
||||
|
||||
for (const sourcePath of sourcePaths) {
|
||||
const filePath = join(projectPath, ...sourcePath.split('\\'));
|
||||
const contents = await this.readProjectFile(filePath);
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'retain-previous') {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
instructions.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = contents.value.trim();
|
||||
if (!content) {
|
||||
continue;
|
||||
}
|
||||
|
||||
instructions.push({
|
||||
id: buildProjectCustomizationItemId('instruction', sourcePath),
|
||||
sourcePath,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
return instructions;
|
||||
}
|
||||
|
||||
private async scanAgentProfiles(
|
||||
projectPath: 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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
profiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private async scanPromptFiles(
|
||||
projectPath: 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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (contents.kind === 'missing') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedFile = parseProjectFrontmatter(contents.value, sourcePath);
|
||||
if (!parsedFile) {
|
||||
const existing = previousByPath.get(sourcePath);
|
||||
if (existing) {
|
||||
promptFiles.push(existing);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const template = parsedFile.body.trim();
|
||||
if (!template) {
|
||||
continue;
|
||||
}
|
||||
|
||||
promptFiles.push({
|
||||
id: buildProjectCustomizationItemId('prompt', sourcePath),
|
||||
name: basename(filePath, '.prompt.md'),
|
||||
description: readOptionalString(parsedFile.attributes, ['description']),
|
||||
agent: readOptionalString(parsedFile.attributes, ['agent']),
|
||||
template,
|
||||
variables: extractPromptVariables(template),
|
||||
sourcePath,
|
||||
});
|
||||
}
|
||||
|
||||
return promptFiles;
|
||||
}
|
||||
|
||||
private async listProjectFiles(directoryPath: string, suffix: string): Promise<string[]> {
|
||||
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));
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return [];
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to read directory ${directoryPath}:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async readProjectFile(filePath: string): Promise<
|
||||
| { kind: 'success'; value: string }
|
||||
| { kind: 'missing' }
|
||||
| { kind: 'retain-previous' }
|
||||
> {
|
||||
try {
|
||||
return {
|
||||
kind: 'success',
|
||||
value: await readFile(filePath, 'utf8'),
|
||||
};
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { kind: 'missing' };
|
||||
}
|
||||
|
||||
console.warn(`[aryx customization] Failed to read ${filePath}:`, error);
|
||||
return { kind: 'retain-previous' };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseProjectFrontmatter(
|
||||
contents: string,
|
||||
sourcePath: string,
|
||||
): { attributes: Record<string, unknown>; body: string } | undefined {
|
||||
const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/u.exec(contents);
|
||||
if (!match) {
|
||||
return {
|
||||
attributes: {},
|
||||
body: contents,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = parseYaml(match[1]);
|
||||
if (!isPlainObject(parsed) && parsed !== null && parsed !== undefined) {
|
||||
console.warn(`[aryx customization] Ignoring non-object frontmatter in ${sourcePath}.`);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
attributes: isPlainObject(parsed) ? parsed : {},
|
||||
body: match[2],
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn(`[aryx customization] Failed to parse frontmatter in ${sourcePath}:`, error);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPromptVariables(template: string): ProjectPromptVariable[] {
|
||||
const variables: ProjectPromptVariable[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = promptVariablePattern.exec(template))) {
|
||||
const name = match[1]?.trim();
|
||||
if (!name || seenNames.has(name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenNames.add(name);
|
||||
variables.push({
|
||||
name,
|
||||
placeholder: match[2]?.trim() ?? '',
|
||||
});
|
||||
}
|
||||
|
||||
promptVariablePattern.lastIndex = 0;
|
||||
return variables;
|
||||
}
|
||||
|
||||
function buildProjectCustomizationItemId(kind: 'instruction' | 'agent' | 'prompt', sourcePath: string): string {
|
||||
return `project_customization_${kind}_${normalizeIdentifierSegment(sourcePath)}`;
|
||||
}
|
||||
|
||||
function toProjectSourcePath(projectPath: string, filePath: string): string {
|
||||
const relativePath = relative(projectPath, filePath).trim();
|
||||
return relativePath ? relativePath.replaceAll('/', '\\') : basename(filePath);
|
||||
}
|
||||
|
||||
function normalizeIdentifierSegment(value: string): string {
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
|
||||
return normalized.length > 0 ? normalized : 'item';
|
||||
}
|
||||
|
||||
function readOptionalString(
|
||||
record: Record<string, unknown>,
|
||||
keys: ReadonlyArray<string>,
|
||||
): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length > 0) {
|
||||
return trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readOptionalStringArray(value: unknown): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? [trimmed] : [];
|
||||
}
|
||||
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [...new Set(value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0))];
|
||||
}
|
||||
|
||||
function readOptionalNamedObjectMap(
|
||||
value: unknown,
|
||||
): Record<string, Record<string, unknown>> | undefined {
|
||||
if (!isPlainObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const entries = Object.entries(value)
|
||||
.map(([name, config]) => [name.trim(), normalizeYamlValue(config)] as const)
|
||||
.filter(([name, config]) => name.length > 0 && isPlainObject(config))
|
||||
.sort(([leftName], [rightName]) => leftName.localeCompare(rightName));
|
||||
|
||||
if (entries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(entries.map(([name, config]) => [name, config as Record<string, unknown>]));
|
||||
}
|
||||
|
||||
function normalizeYamlValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((entry) => normalizeYamlValue(entry));
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return typeof value === 'string' ? value.trim() : value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, nestedValue]) => [key.trim(), normalizeYamlValue(nestedValue)] as const)
|
||||
.filter(([key]) => key.length > 0)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -15,8 +15,12 @@ const api: ElectronApi = {
|
||||
ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input),
|
||||
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
|
||||
rescanProjectConfigs: (input) => ipcRenderer.invoke(ipcChannels.rescanProjectConfigs, input),
|
||||
rescanProjectCustomization: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.rescanProjectCustomization, input),
|
||||
resolveProjectDiscoveredTooling: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
|
||||
setProjectAgentProfileEnabled: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
|
||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||
|
||||
@@ -7,7 +7,9 @@ export const ipcChannels = {
|
||||
resolveWorkspaceDiscoveredTooling: 'workspace:resolve-discovered-tooling',
|
||||
refreshProjectGitContext: 'projects:refresh-git-context',
|
||||
rescanProjectConfigs: 'project:rescan-configs',
|
||||
rescanProjectCustomization: 'project:rescan-customization',
|
||||
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
|
||||
setProjectAgentProfileEnabled: 'project:set-agent-profile-enabled',
|
||||
savePattern: 'patterns:save',
|
||||
deletePattern: 'patterns:delete',
|
||||
setPatternFavorite: 'patterns:set-favorite',
|
||||
|
||||
@@ -91,12 +91,22 @@ export interface RescanProjectConfigsInput {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface RescanProjectCustomizationInput {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ResolveProjectDiscoveredToolingInput {
|
||||
projectId: string;
|
||||
serverIds: string[];
|
||||
resolution: DiscoveredToolingResolution;
|
||||
}
|
||||
|
||||
export interface SetProjectAgentProfileEnabledInput {
|
||||
projectId: string;
|
||||
agentProfileId: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveWorkspaceDiscoveredToolingInput {
|
||||
serverIds: string[];
|
||||
resolution: DiscoveredToolingResolution;
|
||||
@@ -141,7 +151,9 @@ export interface ElectronApi {
|
||||
resolveWorkspaceDiscoveredTooling(input: ResolveWorkspaceDiscoveredToolingInput): Promise<WorkspaceState>;
|
||||
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
||||
rescanProjectConfigs(input: RescanProjectConfigsInput): Promise<WorkspaceState>;
|
||||
rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise<WorkspaceState>;
|
||||
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
|
||||
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
|
||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -80,6 +80,7 @@ export interface RunTurnCommand {
|
||||
workspaceKind?: 'project' | 'scratchpad';
|
||||
mode?: InteractionMode;
|
||||
messageMode?: MessageMode;
|
||||
projectInstructions?: string;
|
||||
pattern: PatternDefinition;
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
import type { ProjectDiscoveredTooling } from '@shared/domain/discoveredTooling';
|
||||
import type { ProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||
|
||||
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
|
||||
|
||||
@@ -39,6 +40,7 @@ export interface ProjectRecord {
|
||||
addedAt: string;
|
||||
git?: ProjectGitContext;
|
||||
discoveredTooling?: ProjectDiscoveredTooling;
|
||||
customization?: ProjectCustomizationState;
|
||||
}
|
||||
|
||||
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
export interface ProjectInstructionFile {
|
||||
id: string;
|
||||
sourcePath: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ProjectAgentProfileMcpServerConfig {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProjectAgentProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
tools?: string[];
|
||||
prompt: string;
|
||||
mcpServers?: Record<string, ProjectAgentProfileMcpServerConfig>;
|
||||
infer?: boolean;
|
||||
sourcePath: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectPromptVariable {
|
||||
name: string;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
export interface ProjectPromptFile {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
agent?: string;
|
||||
template: string;
|
||||
variables: ProjectPromptVariable[];
|
||||
sourcePath: string;
|
||||
}
|
||||
|
||||
export interface ProjectCustomizationState {
|
||||
instructions: ProjectInstructionFile[];
|
||||
agentProfiles: ProjectAgentProfile[];
|
||||
promptFiles: ProjectPromptFile[];
|
||||
lastScannedAt?: string;
|
||||
}
|
||||
|
||||
export function createProjectCustomizationState(): ProjectCustomizationState {
|
||||
return {
|
||||
instructions: [],
|
||||
agentProfiles: [],
|
||||
promptFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProjectCustomizationState(
|
||||
value?: Partial<ProjectCustomizationState>,
|
||||
): ProjectCustomizationState {
|
||||
return {
|
||||
instructions: (value?.instructions ?? [])
|
||||
.map(normalizeProjectInstructionFile)
|
||||
.filter((instruction) => instruction.content.length > 0)
|
||||
.sort(compareProjectFiles),
|
||||
agentProfiles: (value?.agentProfiles ?? [])
|
||||
.map(normalizeProjectAgentProfile)
|
||||
.filter((profile) => profile.name.length > 0 && profile.prompt.length > 0)
|
||||
.sort(compareProjectFiles),
|
||||
promptFiles: (value?.promptFiles ?? [])
|
||||
.map(normalizeProjectPromptFile)
|
||||
.filter((promptFile) => promptFile.name.length > 0 && promptFile.template.length > 0)
|
||||
.sort(compareProjectFiles),
|
||||
lastScannedAt: normalizeOptionalString(value?.lastScannedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function mergeProjectCustomizationState(
|
||||
current: ProjectCustomizationState | undefined,
|
||||
scanned: Omit<ProjectCustomizationState, 'lastScannedAt'>,
|
||||
lastScannedAt: string,
|
||||
): ProjectCustomizationState {
|
||||
const normalizedCurrent = normalizeProjectCustomizationState(current);
|
||||
const currentProfilesById = new Map(
|
||||
normalizedCurrent.agentProfiles.map((profile) => [profile.id, profile]),
|
||||
);
|
||||
|
||||
return {
|
||||
instructions: scanned.instructions
|
||||
.map(normalizeProjectInstructionFile)
|
||||
.filter((instruction) => instruction.content.length > 0)
|
||||
.sort(compareProjectFiles),
|
||||
agentProfiles: scanned.agentProfiles
|
||||
.map(normalizeProjectAgentProfile)
|
||||
.filter((profile) => profile.name.length > 0 && profile.prompt.length > 0)
|
||||
.map((profile) => ({
|
||||
...profile,
|
||||
enabled: currentProfilesById.get(profile.id)?.enabled ?? profile.enabled,
|
||||
}))
|
||||
.sort(compareProjectFiles),
|
||||
promptFiles: scanned.promptFiles
|
||||
.map(normalizeProjectPromptFile)
|
||||
.filter((promptFile) => promptFile.name.length > 0 && promptFile.template.length > 0)
|
||||
.sort(compareProjectFiles),
|
||||
lastScannedAt,
|
||||
};
|
||||
}
|
||||
|
||||
export function listEnabledProjectAgentProfiles(
|
||||
state?: Partial<ProjectCustomizationState>,
|
||||
): ProjectAgentProfile[] {
|
||||
return normalizeProjectCustomizationState(state).agentProfiles.filter((profile) => profile.enabled);
|
||||
}
|
||||
|
||||
export function resolveProjectInstructionsContent(
|
||||
state?: Partial<ProjectCustomizationState>,
|
||||
): string | undefined {
|
||||
const content = normalizeProjectCustomizationState(state).instructions
|
||||
.map((instruction) => instruction.content)
|
||||
.filter((value) => value.length > 0)
|
||||
.join('\n\n')
|
||||
.trim();
|
||||
|
||||
return content.length > 0 ? content : undefined;
|
||||
}
|
||||
|
||||
export function setProjectAgentProfileEnabled(
|
||||
state: ProjectCustomizationState | undefined,
|
||||
agentProfileId: string,
|
||||
enabled: boolean,
|
||||
): ProjectCustomizationState {
|
||||
const normalizedState = normalizeProjectCustomizationState(state);
|
||||
return {
|
||||
...normalizedState,
|
||||
agentProfiles: normalizedState.agentProfiles.map((profile) =>
|
||||
profile.id === agentProfileId
|
||||
? {
|
||||
...profile,
|
||||
enabled,
|
||||
}
|
||||
: profile),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectInstructionFile(file: ProjectInstructionFile): ProjectInstructionFile {
|
||||
return {
|
||||
id: file.id.trim(),
|
||||
sourcePath: normalizePathLikeString(file.sourcePath),
|
||||
content: file.content.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectAgentProfile(profile: ProjectAgentProfile): ProjectAgentProfile {
|
||||
const tools = normalizeOptionalStringArray(profile.tools);
|
||||
const normalizedProfile: ProjectAgentProfile = {
|
||||
id: profile.id.trim(),
|
||||
name: profile.name.trim(),
|
||||
prompt: profile.prompt.trim(),
|
||||
sourcePath: normalizePathLikeString(profile.sourcePath),
|
||||
enabled: profile.enabled !== false,
|
||||
};
|
||||
|
||||
const displayName = normalizeOptionalString(profile.displayName);
|
||||
if (displayName) {
|
||||
normalizedProfile.displayName = displayName;
|
||||
}
|
||||
|
||||
const description = normalizeOptionalString(profile.description);
|
||||
if (description) {
|
||||
normalizedProfile.description = description;
|
||||
}
|
||||
|
||||
if (tools) {
|
||||
normalizedProfile.tools = tools;
|
||||
}
|
||||
|
||||
const mcpServers = normalizeOptionalMcpServers(profile.mcpServers);
|
||||
if (mcpServers) {
|
||||
normalizedProfile.mcpServers = mcpServers;
|
||||
}
|
||||
|
||||
if (typeof profile.infer === 'boolean') {
|
||||
normalizedProfile.infer = profile.infer;
|
||||
}
|
||||
|
||||
return normalizedProfile;
|
||||
}
|
||||
|
||||
function normalizeProjectPromptFile(promptFile: ProjectPromptFile): ProjectPromptFile {
|
||||
const normalizedPromptFile: ProjectPromptFile = {
|
||||
id: promptFile.id.trim(),
|
||||
name: promptFile.name.trim(),
|
||||
template: promptFile.template.trim(),
|
||||
variables: promptFile.variables
|
||||
.map((variable) => ({
|
||||
name: variable.name.trim(),
|
||||
placeholder: variable.placeholder.trim(),
|
||||
}))
|
||||
.filter((variable) => variable.name.length > 0),
|
||||
sourcePath: normalizePathLikeString(promptFile.sourcePath),
|
||||
};
|
||||
|
||||
const description = normalizeOptionalString(promptFile.description);
|
||||
if (description) {
|
||||
normalizedPromptFile.description = description;
|
||||
}
|
||||
|
||||
const agent = normalizeOptionalString(promptFile.agent);
|
||||
if (agent) {
|
||||
normalizedPromptFile.agent = agent;
|
||||
}
|
||||
|
||||
return normalizedPromptFile;
|
||||
}
|
||||
|
||||
function compareProjectFiles(
|
||||
left: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
|
||||
right: Pick<ProjectInstructionFile | ProjectAgentProfile | ProjectPromptFile, 'sourcePath' | 'id'>,
|
||||
): number {
|
||||
return left.sourcePath.localeCompare(right.sourcePath) || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function normalizeOptionalMcpServers(
|
||||
value?: Record<string, ProjectAgentProfileMcpServerConfig>,
|
||||
): Record<string, ProjectAgentProfileMcpServerConfig> | undefined {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedEntries = Object.entries(value)
|
||||
.map(([name, config]) => [name.trim(), normalizeYamlValue(config)] as const)
|
||||
.filter(([name, config]) => name.length > 0 && isPlainObject(config))
|
||||
.sort(([leftName], [rightName]) => leftName.localeCompare(rightName));
|
||||
|
||||
if (normalizedEntries.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
normalizedEntries.map(([name, config]) => [name, config as ProjectAgentProfileMcpServerConfig]),
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalStringArray(values?: ReadonlyArray<string>): string[] | undefined {
|
||||
if (!values) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return [...new Set(values.map((value) => value.trim()).filter((value) => value.length > 0))];
|
||||
}
|
||||
|
||||
function normalizePathLikeString(value: string): string {
|
||||
return value.trim().replaceAll('/', '\\');
|
||||
}
|
||||
|
||||
function normalizeYamlValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(normalizeYamlValue);
|
||||
}
|
||||
|
||||
if (!isPlainObject(value)) {
|
||||
return typeof value === 'string' ? value.trim() : value;
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.map(([key, nestedValue]) => [key.trim(), normalizeYamlValue(nestedValue)] as const)
|
||||
.filter(([key]) => key.length > 0)
|
||||
.sort(([leftKey], [rightKey]) => leftKey.localeCompare(rightKey)),
|
||||
);
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
Reference in New Issue
Block a user