From 75b9ff667a06795b94e25845a4d78f084223792b Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 28 Mar 2026 17:29:37 +0100 Subject: [PATCH] feat: support project copilot customization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 4 + bun.lock | 3 + package.json | 3 +- .../Contracts/ProtocolModels.cs | 1 + .../Services/AgentInstructionComposer.cs | 10 +- .../Services/CopilotAgentBundle.cs | 3 +- .../AgentInstructionComposerTests.cs | 33 ++ .../CopilotAgentBundleTests.cs | 37 ++ src/main/AryxAppService.ts | 181 ++++++++- src/main/ipc/registerIpcHandlers.ts | 22 +- src/main/persistence/workspaceRepository.ts | 2 + src/main/services/customizationScanner.ts | 370 ++++++++++++++++++ src/preload/index.ts | 4 + src/shared/contracts/channels.ts | 2 + src/shared/contracts/ipc.ts | 12 + src/shared/contracts/sidecar.ts | 1 + src/shared/domain/project.ts | 2 + src/shared/domain/projectCustomization.ts | 276 +++++++++++++ .../appServiceProjectCustomization.test.ts | 244 ++++++++++++ tests/main/customizationScanner.test.ts | 160 ++++++++ 20 files changed, 1354 insertions(+), 16 deletions(-) create mode 100644 src/main/services/customizationScanner.ts create mode 100644 src/shared/domain/projectCustomization.ts create mode 100644 tests/main/appServiceProjectCustomization.test.ts create mode 100644 tests/main/customizationScanner.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ade34ca..b3d2406 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -124,6 +124,8 @@ Projects are the container for context. There are two kinds: The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations. +Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `.github/agents/*.agent.md`, and `.github/prompts/*.prompt.md`. The main process owns that scan step and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. + ### Patterns Patterns describe how agents collaborate. The architecture supports: @@ -215,6 +217,8 @@ These events flow through a single `onTurnScopedEvent` callback on the `runTurn` For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy. +The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from repo-level instruction files and merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. The sidecar then folds those project instructions into the final SDK system message alongside the agent's own instructions and runtime guidance. + ## Security model Security in this system is mostly about **desktop trust boundaries**. diff --git a/bun.lock b/bun.lock index 41d65ca..345193a 100644 --- a/bun.lock +++ b/bun.lock @@ -38,6 +38,7 @@ "typescript": "^5.9.3", "typescript-language-server": "^5.1.3", "vite": "7.1.10", + "yaml": "^2.8.3", }, }, }, @@ -904,6 +905,8 @@ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + "yaml": ["yaml@2.8.3", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg=="], + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "yjs": ["yjs@13.6.30", "", { "dependencies": { "lib0": "^0.2.99" } }, "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ=="], diff --git a/package.json b/package.json index a6ae206..4af11b0 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,8 @@ "tailwindcss": "^4.2.2", "typescript": "^5.9.3", "typescript-language-server": "^5.1.3", - "vite": "7.1.10" + "vite": "7.1.10", + "yaml": "^2.8.3" }, "dependencies": { "keytar": "^7.9.0" diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 61b671e..bf8b30d 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -183,6 +183,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public string WorkspaceKind { get; init; } = "project"; public string Mode { get; init; } = "interactive"; public string MessageMode { get; init; } = "enqueue"; + public string? ProjectInstructions { get; init; } public PatternDefinitionDto Pattern { get; init; } = new(); public IReadOnlyList Messages { get; init; } = []; public RunTurnToolingConfigDto? Tooling { get; init; } diff --git a/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs b/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs index a1be204..35d08d7 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs @@ -9,9 +9,11 @@ internal static class AgentInstructionComposer PatternAgentDefinitionDto agent, int agentIndex, string workspaceKind = "project", - string interactionMode = "interactive") + string interactionMode = "interactive", + string? projectInstructions = null) { string baseInstructions = agent.Instructions.Trim(); + string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty; string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase) ? """ You are operating in scratchpad mode. @@ -46,12 +48,12 @@ internal static class AgentInstructionComposer Focus on refining the answer already in progress. """; - return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance); + return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance); } if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) { - return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance); + return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance); } string runtimeGuidance = agentIndex == 0 @@ -69,7 +71,7 @@ internal static class AgentInstructionComposer Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty. """; - return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance); + return JoinInstructionBlocks(baseInstructions, repositoryInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance); } private static string JoinInstructionBlocks(params string[] blocks) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index 9349262..057e271 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -103,7 +103,8 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable definition, agentIndex, command.WorkspaceKind, - command.Mode), + command.Mode, + command.ProjectInstructions), }, WorkingDirectory = command.ProjectPath, OnPermissionRequest = onPermissionRequest, diff --git a/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs index 3a01ed0..1a805e2 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs @@ -151,6 +151,39 @@ public sealed class AgentInstructionComposerTests Assert.Contains("Do not continue into implementation", instructions, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Compose_InsertsProjectInstructionsBetweenBaseAndRuntimeGuidance() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-single", + Name = "Single", + Mode = "single", + Availability = "available", + }; + PatternAgentDefinitionDto agent = CreateAgent( + id: "agent-primary", + name: "Primary Agent", + instructions: "You are a helpful assistant."); + + string instructions = AgentInstructionComposer.Compose( + pattern, + agent, + agentIndex: 0, + workspaceKind: "scratchpad", + projectInstructions: "Follow the repository guide."); + + Assert.Contains("You are a helpful assistant.", instructions, StringComparison.Ordinal); + Assert.Contains("Follow the repository guide.", instructions, StringComparison.Ordinal); + Assert.Contains("scratchpad mode", instructions, StringComparison.OrdinalIgnoreCase); + Assert.True( + instructions.IndexOf("You are a helpful assistant.", StringComparison.Ordinal) + < instructions.IndexOf("Follow the repository guide.", StringComparison.Ordinal)); + Assert.True( + instructions.IndexOf("Follow the repository guide.", StringComparison.Ordinal) + < instructions.IndexOf("scratchpad mode", StringComparison.OrdinalIgnoreCase)); + } + private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions) { return new PatternAgentDefinitionDto diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index 74ac12a..bc909a9 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -250,6 +250,43 @@ public sealed class CopilotAgentBundleTests Assert.NotNull(sessionConfig.Hooks); } + [Fact] + public void CreateSessionConfig_PassesProjectInstructionsIntoTheSystemMessage() + { + RunTurnCommandDto command = new() + { + SessionId = "session-1", + ProjectPath = @"C:\workspace\project", + WorkspaceKind = "project", + Mode = "interactive", + ProjectInstructions = "Follow repository guidance.", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Pattern", + Mode = "single", + Availability = "available", + Agents = + [ + new PatternAgentDefinitionDto + { + Id = "agent-1", + Name = "Primary", + Model = "gpt-5.4", + Instructions = "Help.", + }, + ], + }, + }; + + SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig( + command, + command.Pattern.Agents[0], + agentIndex: 0); + + Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content); + } + [Fact] public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse() { diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index d022611..dcd747f 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { 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(); private readonly pendingUserInputHandles = new Map(); private workspace?: WorkspaceState; @@ -193,11 +204,15 @@ export class AryxAppService extends EventEmitter { 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 { 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 { 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 { return this.persistAndBroadcast(workspace); } + async rescanProjectCustomization(projectId: string): Promise { + 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 { + 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 { } 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 { 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 { ? [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 { @@ -1169,6 +1231,7 @@ export class AryxAppService extends EventEmitter { 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 { 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 { 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 { return true; } + private async syncProjectCustomization(project: ProjectRecord): Promise { + 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 { === 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, diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 6f15dcd..4f020d6 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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) => diff --git a/src/main/persistence/workspaceRepository.ts b/src/main/persistence/workspaceRepository.ts index 79cafa4..82af88e 100644 --- a/src/main/persistence/workspaceRepository.ts +++ b/src/main/persistence/workspaceRepository.ts @@ -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, ); diff --git a/src/main/services/customizationScanner.ts b/src/main/services/customizationScanner.ts new file mode 100644 index 0000000..7513f09 --- /dev/null +++ b/src/main/services/customizationScanner.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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; 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(); + + 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, + keys: ReadonlyArray, +): 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> | 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])); +} + +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 { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 8a3d50f..c54db14 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 3bb3a45..1d3809d 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 0048b69..3f560fd 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -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; refreshProjectGitContext(projectId?: string): Promise; rescanProjectConfigs(input: RescanProjectConfigsInput): Promise; + rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise; resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise; + setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise; savePattern(input: SavePatternInput): Promise; deletePattern(patternId: string): Promise; saveMcpServer(input: SaveMcpServerInput): Promise; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 70d102f..87aee7e 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -80,6 +80,7 @@ export interface RunTurnCommand { workspaceKind?: 'project' | 'scratchpad'; mode?: InteractionMode; messageMode?: MessageMode; + projectInstructions?: string; pattern: PatternDefinition; messages: ChatMessageRecord[]; attachments?: ChatMessageAttachment[]; diff --git a/src/shared/domain/project.ts b/src/shared/domain/project.ts index 03789e4..608253a 100644 --- a/src/shared/domain/project.ts +++ b/src/shared/domain/project.ts @@ -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'; diff --git a/src/shared/domain/projectCustomization.ts b/src/shared/domain/projectCustomization.ts new file mode 100644 index 0000000..1c0e436 --- /dev/null +++ b/src/shared/domain/projectCustomization.ts @@ -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; + 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 { + 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, + 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, +): ProjectAgentProfile[] { + return normalizeProjectCustomizationState(state).agentProfiles.filter((profile) => profile.enabled); +} + +export function resolveProjectInstructionsContent( + state?: Partial, +): 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, + right: Pick, +): number { + return left.sourcePath.localeCompare(right.sourcePath) || left.id.localeCompare(right.id); +} + +function normalizeOptionalMcpServers( + value?: Record, +): Record | 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[] | 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 { + return !!value && typeof value === 'object' && !Array.isArray(value); +} diff --git a/tests/main/appServiceProjectCustomization.test.ts b/tests/main/appServiceProjectCustomization.test.ts new file mode 100644 index 0000000..5a793ab --- /dev/null +++ b/tests/main/appServiceProjectCustomization.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import type { RunTurnCommand } from '@shared/contracts/sidecar'; +import type { PatternDefinition } from '@shared/domain/pattern'; +import type { ProjectRecord } from '@shared/domain/project'; +import type { SessionRecord } from '@shared/domain/session'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +const TIMESTAMP = '2026-03-28T00:00:00.000Z'; + +mock.module('electron', () => { + const electronMock = { + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', + getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures', + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { AryxAppService } = await import('@main/AryxAppService'); + +function createProject(overrides?: Partial): ProjectRecord { + return { + id: 'project-alpha', + name: 'alpha', + path: 'C:\\workspace\\alpha', + addedAt: TIMESTAMP, + ...overrides, + }; +} + +function createSession(projectId: string, patternId: string, overrides?: Partial): SessionRecord { + return { + id: 'session-alpha', + projectId, + patternId, + title: 'Alpha session', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + status: 'idle', + messages: [], + runs: [], + ...overrides, + }; +} + +function createService( + workspace: WorkspaceState, + pattern: PatternDefinition, + options?: { + captureRunTurn?: (command: RunTurnCommand) => void; + }, +): InstanceType { + const service = new AryxAppService(); + const internals = service as unknown as Record; + internals.loadWorkspace = async () => workspace; + internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace; + internals.buildEffectivePattern = async () => pattern; + internals.awaitFinalResponseApproval = async () => undefined; + internals.finalizeTurn = () => undefined; + internals.emitSessionEvent = () => undefined; + internals.pruneUnavailableApprovalTools = async () => false; + internals.pruneUnavailableSessionToolingSelections = () => false; + ( + service as unknown as { + sidecar: { + runTurn: (command: RunTurnCommand) => Promise<[]>; + resolveApproval: () => Promise; + }; + } + ).sidecar = { + runTurn: async (command) => { + options?.captureRunTurn?.(command); + return []; + }, + resolveApproval: async () => undefined, + }; + + return service; +} + +describe('AryxAppService project customization', () => { + test('sendSessionMessage injects project instructions and enabled project agent profiles', async () => { + const workspace = createWorkspaceSeed(); + const basePattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); + if (!basePattern) { + throw new Error('Expected a single-agent pattern in the workspace seed.'); + } + + const pattern: PatternDefinition = { + ...basePattern, + agents: [ + { + ...basePattern.agents[0]!, + copilot: { + customAgents: [ + { + name: 'reviewer', + prompt: 'Built-in reviewer prompt.', + }, + ], + }, + }, + ], + }; + + const project = createProject({ + customization: { + instructions: [ + { + id: 'instruction-repo', + sourcePath: '.github\\copilot-instructions.md', + content: 'Use TypeScript.', + }, + { + id: 'instruction-agents', + sourcePath: 'AGENTS.md', + content: 'Prefer focused tests.', + }, + ], + agentProfiles: [ + { + id: 'agent-reviewer', + name: 'reviewer', + prompt: 'Project reviewer prompt.', + sourcePath: '.github\\agents\\reviewer.agent.md', + enabled: true, + }, + { + id: 'agent-readme', + name: 'readme-specialist', + description: 'Documentation specialist', + tools: ['read', 'edit'], + prompt: 'Focus on README improvements.', + sourcePath: '.github\\agents\\readme-specialist.agent.md', + enabled: true, + }, + { + id: 'agent-disabled', + name: 'disabled-specialist', + prompt: 'Should never be injected.', + sourcePath: '.github\\agents\\disabled-specialist.agent.md', + enabled: false, + }, + ], + promptFiles: [], + lastScannedAt: TIMESTAMP, + }, + }); + const session = createSession(project.id, pattern.id); + + workspace.projects = [project]; + workspace.sessions = [session]; + workspace.selectedProjectId = project.id; + workspace.selectedPatternId = pattern.id; + workspace.selectedSessionId = session.id; + + let command: RunTurnCommand | undefined; + const service = createService(workspace, pattern, { + captureRunTurn: (capturedCommand) => { + command = capturedCommand; + }, + }); + + await service.sendSessionMessage(session.id, 'Use the repository guidance.'); + + expect(command?.projectInstructions).toBe('Use TypeScript.\n\nPrefer focused tests.'); + expect(command?.pattern.agents[0]?.copilot?.customAgents).toEqual([ + { + name: 'reviewer', + prompt: 'Built-in reviewer prompt.', + }, + { + name: 'readme-specialist', + description: 'Documentation specialist', + tools: ['read', 'edit'], + prompt: 'Focus on README improvements.', + infer: undefined, + }, + ]); + }); + + test('setProjectAgentProfileEnabled persists the updated enabled state', async () => { + const workspace = createWorkspaceSeed(); + const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); + if (!pattern) { + throw new Error('Expected a single-agent pattern in the workspace seed.'); + } + + const project = createProject({ + customization: { + instructions: [], + agentProfiles: [ + { + id: 'agent-readme', + name: 'readme-specialist', + prompt: 'Focus on docs.', + sourcePath: '.github\\agents\\readme-specialist.agent.md', + enabled: true, + }, + ], + promptFiles: [], + lastScannedAt: TIMESTAMP, + }, + }); + const session = createSession(project.id, pattern.id); + + workspace.projects = [project]; + workspace.sessions = [session]; + + const service = createService(workspace, pattern); + const updated = await service.setProjectAgentProfileEnabled(project.id, 'agent-readme', false); + + expect(updated.projects[0]?.customization?.agentProfiles).toEqual([ + { + id: 'agent-readme', + name: 'readme-specialist', + prompt: 'Focus on docs.', + sourcePath: '.github\\agents\\readme-specialist.agent.md', + enabled: false, + }, + ]); + }); +}); diff --git a/tests/main/customizationScanner.test.ts b/tests/main/customizationScanner.test.ts new file mode 100644 index 0000000..39d243f --- /dev/null +++ b/tests/main/customizationScanner.test.ts @@ -0,0 +1,160 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { ProjectCustomizationScanner } from '@main/services/customizationScanner'; + +const temporaryPaths: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryPaths.splice(0).map((path) => rm(path, { recursive: true, force: true })), + ); +}); + +async function createTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'aryx-customization-scanner-')); + temporaryPaths.push(directory); + return directory; +} + +describe('ProjectCustomizationScanner', () => { + test('discovers project instructions, custom agents, and prompt files', async () => { + const projectPath = await createTempDirectory(); + await mkdir(join(projectPath, '.github', 'agents'), { recursive: true }); + await mkdir(join(projectPath, '.github', 'prompts'), { recursive: true }); + + await writeFile( + join(projectPath, '.github', 'copilot-instructions.md'), + '# Repo instructions\nUse TypeScript.\n', + 'utf8', + ); + await writeFile( + join(projectPath, 'AGENTS.md'), + '# Agent guidance\nPrefer clear tests.\n', + 'utf8', + ); + await writeFile( + join(projectPath, '.github', 'agents', 'readme-specialist.agent.md'), + `--- +name: readme-specialist +description: README specialist +tools: + - read + - edit +infer: true +mcp-servers: + docs-mcp: + type: local + command: node + args: + - docs-server.js +--- +Focus on repository documentation only. +`, + 'utf8', + ); + await writeFile( + join(projectPath, '.github', 'prompts', 'explain-code.prompt.md'), + `--- +agent: agent +description: Generate a clear explanation +--- +Explain the following code: +\${input:code:Paste your code here} +Audience: \${input:audience:Who is this for?} +`, + 'utf8', + ); + + const scanned = await new ProjectCustomizationScanner().scanProject(projectPath); + + expect(scanned.instructions).toEqual([ + { + id: expect.any(String), + sourcePath: '.github\\copilot-instructions.md', + content: '# Repo instructions\nUse TypeScript.', + }, + { + id: expect.any(String), + sourcePath: 'AGENTS.md', + content: '# Agent guidance\nPrefer clear tests.', + }, + ]); + expect(scanned.agentProfiles).toEqual([ + { + id: expect.any(String), + name: 'readme-specialist', + description: 'README specialist', + tools: ['read', 'edit'], + prompt: 'Focus on repository documentation only.', + infer: true, + mcpServers: { + 'docs-mcp': { + args: ['docs-server.js'], + command: 'node', + type: 'local', + }, + }, + sourcePath: '.github\\agents\\readme-specialist.agent.md', + enabled: true, + }, + ]); + expect(scanned.promptFiles).toEqual([ + { + id: expect.any(String), + name: 'explain-code', + description: 'Generate a clear explanation', + agent: 'agent', + template: 'Explain the following code:\n${input:code:Paste your code here}\nAudience: ${input:audience:Who is this for?}', + variables: [ + { name: 'code', placeholder: 'Paste your code here' }, + { name: 'audience', placeholder: 'Who is this for?' }, + ], + sourcePath: '.github\\prompts\\explain-code.prompt.md', + }, + ]); + expect(scanned.lastScannedAt).toEqual(expect.any(String)); + }); + + test('retains the previous parsed agent profile when frontmatter becomes malformed', async () => { + const projectPath = await createTempDirectory(); + await mkdir(join(projectPath, '.github', 'agents'), { recursive: true }); + const filePath = join(projectPath, '.github', 'agents', 'reviewer.agent.md'); + + await writeFile( + filePath, + `--- +name: reviewer +description: Review specialist +tools: [read, search] +--- +Review code changes carefully. +`, + 'utf8', + ); + + const scanner = new ProjectCustomizationScanner(); + const firstScan = await scanner.scanProject(projectPath); + const previousState = { + ...firstScan, + agentProfiles: firstScan.agentProfiles.map((profile) => ({ ...profile, enabled: false })), + }; + + await writeFile( + filePath, + `--- +name: [reviewer +description: broken yaml +--- +This should not replace the previous state. +`, + 'utf8', + ); + + const secondScan = await scanner.scanProject(projectPath, previousState); + + expect(secondScan.agentProfiles).toEqual(previousState.agentProfiles); + }); +});