diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 992e1215..bf48d9bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,7 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts - name: Audit for critical vulnerabilities run: bun pm audit 2>&1 || true @@ -55,7 +55,7 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts - name: Run ESLint run: bun run lint @@ -76,7 +76,7 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts - name: Run type check run: bun run typecheck @@ -105,7 +105,10 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts + + - name: Ensure Electron binary + run: bun scripts/ensure-electron.ts # Coverage only on ubuntu to avoid duplicate reports - name: Run unit tests with coverage @@ -161,7 +164,10 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts + + - name: Ensure Electron binary + run: bun scripts/ensure-electron.ts - name: Build renderer run: bun run build @@ -202,7 +208,7 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts - name: Run web API tests run: bun run test:web-api @@ -223,7 +229,7 @@ jobs: cache-dependency-path: bun.lock - name: Install dependencies - run: bun install --frozen-lockfile + run: bun scripts/ci-install.ts # Find the latest successful workflow run on main to download its baseline artifact - name: Find baseline run ID diff --git a/electron/main/app-main.ts b/electron/main/app-main.ts index 91de7391..6e8a9d85 100644 --- a/electron/main/app-main.ts +++ b/electron/main/app-main.ts @@ -57,17 +57,19 @@ import { trayManager } from "./services/tray-manager"; import { scheduledTaskService } from "./services/scheduled-task-service"; import { ensureDefaultWorkspace } from "./services/default-workspace"; import { getTerminalService } from "./services/terminal-service"; +import { skillApiService } from "./services/skill-api-service"; +import { skillProjectionService } from "./services/skill-projection-service"; import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_PORT } from "../../shared/ports"; // --- Gateway singleton instances --- const engineManager = new EngineManager(); -const gatewayServer = new GatewayServer(engineManager); +const gatewayServer = new GatewayServer(engineManager, { skillApi: skillApiService }); // Register engine adapters -const openCodeAdapter = new OpenCodeAdapter({ port: OPENCODE_PORT }); -const copilotAdapter = new CopilotSdkAdapter(); -const claudeAdapter = new ClaudeCodeAdapter(); -const codexAdapter = new CodexAdapter(); +const openCodeAdapter = new OpenCodeAdapter({ port: OPENCODE_PORT, skillProjection: skillProjectionService }); +const copilotAdapter = new CopilotSdkAdapter({ skillProjection: skillProjectionService }); +const claudeAdapter = new ClaudeCodeAdapter({ skillProjection: skillProjectionService }); +const codexAdapter = new CodexAdapter({ skillProjection: skillProjectionService }); engineManager.registerAdapter(openCodeAdapter); engineManager.registerAdapter(copilotAdapter); engineManager.registerAdapter(claudeAdapter); diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index 525e4c32..dd9a47b9 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -30,6 +30,7 @@ import type { import { EngineAdapter, MessageBuffer } from "../engine-adapter"; import { claudeLog } from "../../services/logger"; +import type { SkillProjectionProvider } from "../../services/skill-projection-service"; import { inferToolKind, normalizeToolName } from "../../../../src/types/tool-mapping"; import type { EngineType, @@ -87,6 +88,18 @@ interface V2SessionInfo { allowDangerouslySkipPermissions?: boolean; } +interface ClaudeReloadPluginsResponse { + commands?: Array<{ name: string; description?: string; argumentHint?: string }>; + error_count?: number; +} + +interface ClaudeReloadableSession { + reloadPlugins?: () => Promise; + query?: { + reloadPlugins?: () => Promise; + }; +} + // ============================================================================ // Streaming block tracking for content_block_delta accumulation // ============================================================================ @@ -274,6 +287,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { private cachedSkillNames: string[] = []; /** In-flight warmup promise — prevents concurrent warmups and lets listCommands await it */ private warmupPromise: Promise | null = null; + private availableCommandsDirectory: string | undefined; // --- State --- private status: EngineStatus = "stopped"; @@ -339,6 +353,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { private options?: { model?: string; env?: Record; + skillProjection?: SkillProjectionProvider; }, ) { super(); @@ -617,6 +632,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { } async createSession(directory: string, meta?: Record): Promise { + await this.prepareSkillsForDirectory(directory); const normalizedDir = directory.replaceAll("\\", "/"); const sessionId = timeId("cs"); const now = Date.now(); @@ -1811,13 +1827,13 @@ export class ClaudeCodeAdapter extends EngineAdapter { // ========================================================================== override async listCommands(sessionId?: string, directory?: string): Promise { - // Fast path: commands already populated - if (this.availableCommands.length > 0) return this.availableCommands; - // Commands not yet available — trigger warmup if not already running, // then await it so the first listCommands() call returns real data // instead of a hardcoded fallback. const dir = directory || (sessionId && this.sessionDirectories.get(sessionId)) || "."; + if (this.availableCommands.length > 0 && (!this.availableCommandsDirectory || this.availableCommandsDirectory === this.commandDirectoryKey(dir))) { + return this.availableCommands; + } this.triggerWarmup(dir); if (this.warmupPromise) { @@ -1838,13 +1854,23 @@ export class ClaudeCodeAdapter extends EngineAdapter { ]; } + override async refreshSkillsForDirectory(directory: string): Promise { + this.resetCommandCacheForDirectory(directory, true); + await this.prepareSkillsForDirectory(directory); + await this.reloadPluginsForDirectory(directory); + this.triggerWarmup(directory); + if (this.warmupPromise) { + await this.warmupPromise; + } + } + /** * Trigger a warmup if one isn't already in progress and commands haven't * been populated yet. Safe to call multiple times — deduplicates via * warmupPromise. */ private triggerWarmup(directory: string): void { - if (this.availableCommands.length > 0) return; + if (this.availableCommands.length > 0 && (!this.availableCommandsDirectory || this.availableCommandsDirectory === this.commandDirectoryKey(directory))) return; if (this.warmupPromise) return; this.warmupPromise = this.warmupV2Session("warmup", directory) @@ -1852,6 +1878,101 @@ export class ClaudeCodeAdapter extends EngineAdapter { .finally(() => { this.warmupPromise = null; }); } + private commandDirectoryKey(directory: string): string { + return directory.replaceAll("\\", "/"); + } + + private resetCommandCacheForDirectory(directory: string, force = false): void { + const key = this.commandDirectoryKey(directory); + if (!force && (!this.availableCommandsDirectory || this.availableCommandsDirectory === key)) return; + this.availableCommands = []; + this.cachedSkillNames = []; + this.availableCommandsDirectory = undefined; + } + + private async reloadPluginsForDirectory(directory: string): Promise { + const key = this.commandDirectoryKey(directory); + for (const [sessionId, info] of this.v2Sessions) { + if (this.commandDirectoryKey(info.directory) !== key) continue; + + try { + const reloadable = info.session as unknown as ClaudeReloadableSession; + const result = reloadable.reloadPlugins + ? await reloadable.reloadPlugins() + : reloadable.query?.reloadPlugins + ? await reloadable.query.reloadPlugins() + : undefined; + if (!result) continue; + + this.updateCommandCacheFromClaudeCommands( + result.commands ?? [], + directory, + `reloadPlugins(${sessionId})`, + ); + if ((result.error_count ?? 0) > 0) { + claudeLog.warn(`[Claude][${sessionId}] reloadPlugins completed with ${result.error_count} error(s)`); + } + } catch (error) { + claudeLog.warn(`[Claude][${sessionId}] reloadPlugins failed during skill refresh:`, error); + } + } + } + + private updateCommandCacheFromClaudeCommands( + commands: Array<{ name: string; description?: string; argumentHint?: string }>, + directory: string, + source: string, + ): void { + const nextCommands: EngineCommand[] = commands.map((cmd) => ({ + name: cmd.name, + description: cmd.description || "", + argumentHint: cmd.argumentHint || undefined, + })); + + const existingNames = new Set(nextCommands.map((c) => c.name)); + const cwd = directory.replaceAll("/", process.platform === "win32" ? "\\" : "/"); + for (const dir of [join(homedir(), ".claude", "skills"), join(cwd, ".claude", "skills")]) { + for (const skill of ClaudeCodeAdapter.scanSkillsDir(dir)) { + if (!existingNames.has(skill.name)) { + existingNames.add(skill.name); + nextCommands.push(skill); + } + } + } + + this.availableCommands = nextCommands; + this.cachedSkillNames = this.availableCommands + .filter((cmd) => !this.isBuiltInCommand(cmd.name)) + .map((cmd) => cmd.name); + this.availableCommandsDirectory = this.commandDirectoryKey(directory); + + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.availableCommands, + }); + + claudeLog.info( + `[Claude] ${source}: cached ${this.availableCommands.length} commands ` + + `(${this.cachedSkillNames.length} user skills: ${this.cachedSkillNames.join(", ")})`, + ); + } + + private async prepareSkillsForDirectory(directory: string): Promise { + this.resetCommandCacheForDirectory(directory); + if (!this.options?.skillProjection) return; + + try { + const projection = await this.options.skillProjection.prepareForEngine(this.engineType, directory); + if (projection.conflicts.length > 0) { + claudeLog.warn( + `Skill projection for ${directory} completed with ${projection.conflicts.length} conflict(s)`, + ); + } + } catch (error) { + claudeLog.warn(`Failed to prepare skills for ${directory}:`, error); + } + } + override async invokeCommand( sessionId: string, commandName: string, @@ -2025,6 +2146,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { permissionMode?: PermissionMode; }, ): Promise { + await this.prepareSkillsForDirectory(directory); const existing = this.v2Sessions.get(sessionId); if (existing) { // Check if the CLI subprocess is still alive before reusing @@ -2319,8 +2441,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { * Creates a lightweight sdkQuery session, extracts commands, and closes it. */ private async warmupV2Session(sessionId: string, directory: string): Promise { + await this.prepareSkillsForDirectory(directory); + const commandDirectoryKey = this.commandDirectoryKey(directory); // Skip if commands are already populated (e.g. another session already warmed up) - if (this.availableCommands.length > 0) return; + if (this.availableCommands.length > 0 && this.availableCommandsDirectory === commandDirectoryKey) return; const cwd = directory.replaceAll("/", process.platform === "win32" ? "\\" : "/"); const env: Record = { @@ -2344,37 +2468,7 @@ export class ClaudeCodeAdapter extends EngineAdapter { try { const commands = await q.supportedCommands(); - this.availableCommands = commands.map((cmd: { name: string; description: string; argumentHint: string }) => ({ - name: cmd.name, - description: cmd.description || "", - argumentHint: cmd.argumentHint || undefined, - })); - - // Supplement with user-defined skills from .claude/skills/ directories. - // The CC CLI's supportedCommands() API may not include these. - const existingNames = new Set(this.availableCommands.map((c) => c.name)); - const userSkillDirs = [ - join(homedir(), ".claude", "skills"), - join(cwd, ".claude", "skills"), - ]; - for (const dir of userSkillDirs) { - for (const skill of ClaudeCodeAdapter.scanSkillsDir(dir)) { - if (!existingNames.has(skill.name)) { - existingNames.add(skill.name); - this.availableCommands.push(skill); - } - } - } - - // Cache skill names (non-built-in commands) for system prompt injection - this.cachedSkillNames = this.availableCommands - .filter((cmd) => !this.isBuiltInCommand(cmd.name)) - .map((cmd) => cmd.name); - - this.emit("commands.changed", { - engineType: this.engineType, - commands: this.availableCommands, - }); + this.updateCommandCacheFromClaudeCommands(commands, directory, `supportedCommands(${sessionId})`); claudeLog.info( `[Claude][${sessionId}] Warmup complete via supportedCommands(): ${this.availableCommands.length} commands (${this.cachedSkillNames.length} user skills: ${this.cachedSkillNames.join(", ")})`, @@ -2396,6 +2490,10 @@ export class ClaudeCodeAdapter extends EngineAdapter { } } } + this.cachedSkillNames = this.availableCommands + .filter((cmd) => !this.isBuiltInCommand(cmd.name)) + .map((cmd) => cmd.name); + this.availableCommandsDirectory = commandDirectoryKey; this.emit("commands.changed", { engineType: this.engineType, commands: this.availableCommands, diff --git a/electron/main/engines/codex/index.ts b/electron/main/engines/codex/index.ts index 6dc4dfbc..03ca07ec 100644 --- a/electron/main/engines/codex/index.ts +++ b/electron/main/engines/codex/index.ts @@ -31,6 +31,7 @@ import { EngineAdapter, type MessageBuffer } from "../engine-adapter"; import { CODEMUX_IDENTITY_PROMPT } from "../identity-prompt"; import { timeId } from "../../utils/id-gen"; import { codexLog } from "../../services/logger"; +import type { SkillProjectionProvider } from "../../services/skill-projection-service"; import { CodexJsonRpcClient } from "./jsonrpc-client"; import { CODEX_FALLBACK_MODEL, @@ -223,9 +224,15 @@ export class CodexAdapter extends EngineAdapter { private skillsByDirectory = new Map(); private skillEntriesByDirectory = new Map>(); + private skillExtraRootsByDirectory = new Map(); + private skillReloadDirectories = new Set(); private cleanupIntervalId: ReturnType | null = null; + constructor(private options?: { skillProjection?: SkillProjectionProvider }) { + super(); + } + async start(): Promise { if (this.status === "running" && this.client?.running) return; if (this.startPromise) return this.startPromise; @@ -363,6 +370,7 @@ export class CodexAdapter extends EngineAdapter { await this.start(); const normalizedDirectory = normalizeDirectory(directory); + await this.prepareSkillsForDirectory(normalizedDirectory); const existingThreadId = resolveThreadId(undefined, meta); let threadResponse: ThreadResponse; if (existingThreadId) { @@ -502,6 +510,7 @@ export class CodexAdapter extends EngineAdapter { } this.sessionDirectories.set(sessionId, directory); + await this.prepareSkillsForDirectory(directory); if (this.hasActiveTurn(sessionId)) { if (this.canSteer(sessionId, options, directory)) { @@ -781,6 +790,13 @@ export class CodexAdapter extends EngineAdapter { } } + override async refreshSkillsForDirectory(directory: string): Promise { + const normalizedDirectory = normalizeDirectory(directory); + if (!normalizedDirectory) return; + this.skillReloadDirectories.add(normalizedDirectory); + await this.refreshCommandsForDirectory(normalizedDirectory); + } + override async invokeCommand( sessionId: string, commandName: string, @@ -876,6 +892,7 @@ export class CodexAdapter extends EngineAdapter { client.notify("initialized"); this.client = client; + await this.syncCodexSkillExtraRoots(); } catch (error) { try { await client.stop(); @@ -1039,9 +1056,11 @@ export class CodexAdapter extends EngineAdapter { private async refreshCommandsForDirectory(directory: string): Promise { if (!this.client?.running) return this.skillsByDirectory.get(directory) ?? []; + await this.prepareSkillsForDirectory(directory); + const forceReload = this.skillReloadDirectories.delete(directory); const response = asRecord(await this.client.request("skills/list", { cwds: [directory], - forceReload: false, + forceReload, })); const data = Array.isArray(response.data) ? response.data : []; const entry = data @@ -1096,6 +1115,44 @@ export class CodexAdapter extends EngineAdapter { return nextCommands; } + private async prepareSkillsForDirectory(directory: string): Promise { + if (!this.options?.skillProjection || !directory) return; + + try { + const projection = await this.options.skillProjection.prepareForEngine(this.engineType, directory); + if (projection.conflicts.length > 0) { + codexLog.warn( + `Skill projection for ${directory} completed with ${projection.conflicts.length} conflict(s)`, + ); + } + + const previousRoots = this.skillExtraRootsByDirectory.get(directory) ?? []; + const nextRoots = projection.skillDirectories.slice().sort(); + if (nextRoots.length > 0) { + this.skillExtraRootsByDirectory.set(directory, nextRoots); + } else { + this.skillExtraRootsByDirectory.delete(directory); + } + + if (JSON.stringify(previousRoots) !== JSON.stringify(nextRoots)) { + await this.syncCodexSkillExtraRoots(); + this.skillReloadDirectories.add(directory); + } + } catch (error) { + codexLog.warn(`Failed to prepare skills for ${directory}:`, error); + } + } + + private async syncCodexSkillExtraRoots(): Promise { + if (!this.client?.running) return; + const extraRoots = [...new Set([...this.skillExtraRootsByDirectory.values()].flat())].sort(); + try { + await this.client.request("skills/extraRoots/set", { extraRoots }); + } catch (error) { + codexLog.warn("Failed to update Codex skill extra roots:", error); + } + } + private handleNotification(method: string, params: unknown): void { const data = asRecord(params); const threadId = extractThreadId(method, data); @@ -1480,6 +1537,7 @@ export class CodexAdapter extends EngineAdapter { private handleSkillsChanged(): void { for (const directory of this.skillsByDirectory.keys()) { + this.skillReloadDirectories.add(directory); this.refreshCommandsForDirectory(directory).catch((error) => { codexLog.warn(`Failed to refresh skills for ${directory}:`, error); }); diff --git a/electron/main/engines/copilot/index.ts b/electron/main/engines/copilot/index.ts index 5a656062..bbb8aa8c 100644 --- a/electron/main/engines/copilot/index.ts +++ b/electron/main/engines/copilot/index.ts @@ -3,7 +3,7 @@ // ============================================================================ import { writeFileSync, unlinkSync, mkdtempSync, rmdirSync } from "fs"; -import { join } from "path"; +import { join, resolve } from "path"; import { tmpdir } from "os"; import { timeId } from "../../utils/id-gen"; @@ -19,6 +19,7 @@ import type { import { EngineAdapter, MessageBuffer } from "../engine-adapter"; import { CODEMUX_IDENTITY_PROMPT } from "../identity-prompt"; import { copilotLog } from "../../services/logger"; +import type { SkillProjectionProvider } from "../../services/skill-projection-service"; import { inferToolKind, normalizeToolName } from "../../../../src/types/tool-mapping"; import type { EngineType, @@ -223,7 +224,7 @@ export class CopilotSdkAdapter extends EngineAdapter { private toolCallParts = new Map(); private taskCompleteCallIds = new Set(); - constructor(private options?: { cliPath?: string; env?: Record }) { + constructor(private options?: { cliPath?: string; env?: Record; skillProjection?: SkillProjectionProvider }) { super(); } @@ -379,8 +380,9 @@ export class CopilotSdkAdapter extends EngineAdapter { this.ensureClient(); const normalizedDir = directory.replaceAll("\\", "/"); const mode = "autopilot"; + const skillDirectories = await this.getSkillDirectories(directory); - const config: SessionConfig = { + const config: SessionConfig & { skillDirectories?: string[] } = { workingDirectory: directory, streaming: true, model: this.currentModelId ?? undefined, @@ -393,6 +395,7 @@ export class CopilotSdkAdapter extends EngineAdapter { // skills from the slash command list. enableSkills: true, enableConfigDiscovery: true, + ...(skillDirectories ? { skillDirectories } : {}), }; const sdkSession = await this.client!.createSession(config); @@ -425,6 +428,67 @@ export class CopilotSdkAdapter extends EngineAdapter { return session; } + private async getSkillDirectories(directory: string | undefined): Promise { + if (!directory || !this.options?.skillProjection) return undefined; + try { + const projection = await this.options.skillProjection.prepareForEngine(this.engineType, directory); + if (projection.conflicts.length > 0) { + copilotLog.warn( + `Skill projection for ${directory} completed with ${projection.conflicts.length} conflict(s)`, + ); + } + const skillDirectories = projection.skillDirectories.length > 0 ? projection.skillDirectories : undefined; + copilotLog.info( + `[Copilot] prepared ${projection.skillNames.length} skill(s) for ${directory}: ` + + `${projection.skillNames.join(", ") || "none"}; ` + + `skillDirectories=${skillDirectories?.join(", ") ?? "none"}`, + ); + return skillDirectories; + } catch (error) { + copilotLog.warn(`Failed to prepare skills for ${directory}:`, error); + return undefined; + } + } + + override async refreshSkillsForDirectory(directory: string): Promise { + const targetDirectory = this.directoryKey(directory); + if (!targetDirectory) return; + + await this.getSkillDirectories(directory); + this.cachedCommands = []; + + const matchingSessionIds = [...this.sessionDirectories.entries()] + .filter(([, sessionDirectory]) => this.directoryKey(sessionDirectory) === targetDirectory) + .map(([sessionId]) => sessionId); + + for (const sessionId of matchingSessionIds) { + const existing = this.activeSessions.get(sessionId); + if (existing) { + try { + await existing.rpc.skills.reload(); + await this.fetchCommands(existing); + continue; + } catch (error) { + copilotLog.warn(`[Copilot][${sessionId}] skills.reload failed, recreating session:`, error); + this.evictStaleSession(sessionId); + try { + await existing.disconnect(); + } catch (disconnectError) { + copilotLog.debug(`[Copilot][${sessionId}] Failed to disconnect after skill reload failure:`, disconnectError); + } + } + } + + try { + const session = await this.ensureActiveSession(sessionId, directory); + await session.rpc.skills.reload(); + await this.fetchCommands(session); + } catch (error) { + copilotLog.warn(`[Copilot][${sessionId}] Failed to refresh skills for ${directory}:`, error); + } + } + } + hasSession(sessionId: string): boolean { // Check if session is active in memory if (this.activeSessions.has(sessionId)) { @@ -937,6 +1001,7 @@ export class CopilotSdkAdapter extends EngineAdapter { }); const commands = result?.commands ?? []; const merged = new Map(); + const listedSkillNames: string[] = []; for (const cmd of commands) { merged.set(cmd.name, { name: cmd.name, @@ -949,6 +1014,7 @@ export class CopilotSdkAdapter extends EngineAdapter { const skillResult = await session.rpc.skills.list(); const skills = skillResult?.skills ?? []; for (const skill of skills) { + listedSkillNames.push(`${skill.name}:${skill.source ?? "unknown"}`); if (skill.source === "builtin") continue; if (merged.has(skill.name)) continue; merged.set(skill.name, { @@ -962,7 +1028,8 @@ export class CopilotSdkAdapter extends EngineAdapter { this.cachedCommands = Array.from(merged.values()); copilotLog.info( - `[Copilot] fetchCommands: cached ${this.cachedCommands.length} commands`, + `[Copilot] fetchCommands: cached ${this.cachedCommands.length} commands; ` + + `skills=${listedSkillNames.join(", ") || "none"}`, ); this.emit("commands.changed", { engineType: this.engineType, @@ -1242,6 +1309,12 @@ export class CopilotSdkAdapter extends EngineAdapter { this.sessionUnsubscribers.clear(); } + private directoryKey(directory: string | undefined): string | null { + if (!directory) return null; + const resolved = resolve(directory); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; + } + private ensureClient(): void { if (!this.client || this.status !== "running") throw new Error("Copilot SDK adapter is not running"); } @@ -1266,7 +1339,8 @@ export class CopilotSdkAdapter extends EngineAdapter { const workingDirectory = directory || this.sessionDirectories.get(sessionId); const sdkReasoningEffort = this.getSdkReasoningEffort(sessionId); - const config: ResumeSessionConfig = { + const skillDirectories = await this.getSkillDirectories(workingDirectory); + const config: ResumeSessionConfig & { skillDirectories?: string[] } = { streaming: true, workingDirectory, model: this.currentModelId ?? undefined, @@ -1276,6 +1350,7 @@ export class CopilotSdkAdapter extends EngineAdapter { onUserInputRequest: (req, ctx) => this.handleUserInputRequest(req as any, ctx), enableSkills: true, enableConfigDiscovery: true, + ...(skillDirectories ? { skillDirectories } : {}), }; copilotLog.info(`Resuming session ${sessionId}...`); @@ -1290,7 +1365,7 @@ export class CopilotSdkAdapter extends EngineAdapter { const msg = String(err?.message || err); if (msg.includes("not found") || msg.includes("Not found") || msg.includes("no such session")) { copilotLog.warn(`Session ${sessionId} not found on resume, creating new session`); - const newConfig: SessionConfig = { + const newConfig: SessionConfig & { skillDirectories?: string[] } = { streaming: true, workingDirectory, model: this.currentModelId ?? undefined, @@ -1300,6 +1375,7 @@ export class CopilotSdkAdapter extends EngineAdapter { onUserInputRequest: (req, ctx) => this.handleUserInputRequest(req as any, ctx), enableSkills: true, enableConfigDiscovery: true, + ...(skillDirectories ? { skillDirectories } : {}), }; const newSession = await this.client!.createSession(newConfig); this.subscribeToSessionEvents(newSession, sessionId); diff --git a/electron/main/engines/engine-adapter.ts b/electron/main/engines/engine-adapter.ts index b713c33b..f148e852 100644 --- a/electron/main/engines/engine-adapter.ts +++ b/electron/main/engines/engine-adapter.ts @@ -392,6 +392,14 @@ export abstract class EngineAdapter extends EventEmitter { return []; } + /** + * Refresh engine-visible skill state for a workspace after CodeMux changes + * the effective skill set. Engines that cache skill discovery override this. + */ + async refreshSkillsForDirectory(_directory: string): Promise { + /* default: no-op */ + } + /** * Invoke a slash command. Returns a result indicating whether the command * was handled natively or should fall through to sendMessage. diff --git a/electron/main/engines/opencode/index.ts b/electron/main/engines/opencode/index.ts index 48fa9af7..f217f95b 100644 --- a/electron/main/engines/opencode/index.ts +++ b/electron/main/engines/opencode/index.ts @@ -16,6 +16,7 @@ import { import { openCodeLog } from "../../services/logger"; import { CODEMUX_IDENTITY_PROMPT } from "../identity-prompt"; import { EngineAdapter } from "../engine-adapter"; +import type { SkillProjectionProvider } from "../../services/skill-projection-service"; import { convertSession, convertMessage, @@ -113,7 +114,7 @@ export class OpenCodeAdapter extends EngineAdapter { return this.modelPricing.get(`${sdk.providerID}/${sdk.modelID}`); } - constructor(options?: { port?: number }) { + constructor(private options?: { port?: number; skillProjection?: SkillProjectionProvider }) { super(); this.port = options?.port ?? OPENCODE_PORT; } @@ -122,6 +123,20 @@ export class OpenCodeAdapter extends EngineAdapter { return `http://127.0.0.1:${this.port}`; } + private async prepareSkillsForDirectory(directory: string): Promise { + if (!this.options?.skillProjection) return; + try { + const projection = await this.options.skillProjection.prepareForEngine(this.engineType, directory); + if (projection.conflicts.length > 0) { + openCodeLog.warn( + `Skill projection for ${directory} completed with ${projection.conflicts.length} conflict(s)`, + ); + } + } catch (error) { + openCodeLog.warn(`Failed to prepare skills for ${directory}:`, error); + } + } + // --- SDK client management --- private createClient(directory?: string): OpencodeClient { @@ -840,6 +855,7 @@ export class OpenCodeAdapter extends EngineAdapter { // --- Sessions --- async createSession(directory: string): Promise { + await this.prepareSkillsForDirectory(directory); this.switchDirectory(directory); const client = this.ensureClient(); @@ -989,6 +1005,7 @@ export class OpenCodeAdapter extends EngineAdapter { } const dir = session?.directory ?? options?.directory ?? this.currentDirectory ?? undefined; + if (dir) await this.prepareSkillsForDirectory(dir); // --- Enqueue path: engine is already processing this session --- const existingEntries = this.pendingMessages.get(sessionId); @@ -1366,33 +1383,64 @@ export class OpenCodeAdapter extends EngineAdapter { // --- Slash Commands --- - private async fetchCommands(): Promise { + private async fetchCommands(directory = this.currentDirectory ?? undefined): Promise { try { - const client = this.ensureClient(); + if (directory) await this.prepareSkillsForDirectory(directory); + const client = directory ? this.createClient(directory) : this.ensureClient(); const result = await client.command.list({ - directory: this.currentDirectory ?? undefined, + directory, }); const commands = result.data ?? []; + const merged = new Map(); if (Array.isArray(commands)) { - this.cachedCommands = commands.map((cmd) => ({ - name: cmd.name, - description: cmd.description ?? "", - argumentHint: cmd.template ? `<${cmd.template}>` : undefined, - })); - this.emit("commands.changed", { - engineType: this.engineType, - commands: this.cachedCommands, - }); + for (const cmd of commands) { + merged.set(cmd.name, { + name: cmd.name, + description: cmd.description ?? "", + argumentHint: cmd.template ? `<${cmd.template}>` : undefined, + }); + } } + + try { + const skillResult = await client.app.skills({ directory }); + const skills = skillResult.data ?? []; + if (Array.isArray(skills)) { + for (const skill of skills) { + if (!skill.name || merged.has(skill.name)) continue; + merged.set(skill.name, { + name: skill.name, + description: skill.description ?? "", + source: skill.location, + }); + } + } + } catch (skillError) { + openCodeLog.warn("Failed to list OpenCode skills:", skillError); + } + + this.cachedCommands = Array.from(merged.values()); + this.emit("commands.changed", { + engineType: this.engineType, + commands: this.cachedCommands, + }); } catch (err) { openCodeLog.warn("Failed to list commands:", err); } } - override async listCommands(_sessionId?: string): Promise { + override async listCommands(_sessionId?: string, directory?: string): Promise { + if (directory || this.currentDirectory) { + await this.fetchCommands(directory ?? this.currentDirectory ?? undefined); + } return this.cachedCommands; } + override async refreshSkillsForDirectory(directory: string): Promise { + this.cachedCommands = []; + await this.fetchCommands(directory); + } + override async invokeCommand( sessionId: string, commandName: string, @@ -1401,6 +1449,7 @@ export class OpenCodeAdapter extends EngineAdapter { ): Promise { const session = this.sessions.get(sessionId); const dir = session?.directory ?? options?.directory ?? this.currentDirectory ?? undefined; + if (dir) await this.prepareSkillsForDirectory(dir); const client = dir ? this.createClient(dir) : this.ensureClient(); // Build model spec if provided diff --git a/electron/main/gateway/engine-manager.ts b/electron/main/gateway/engine-manager.ts index d9b494fa..b5db3ca9 100644 --- a/electron/main/gateway/engine-manager.ts +++ b/electron/main/gateway/engine-manager.ts @@ -45,6 +45,11 @@ function normalizeDir(dir: string): string { return dir ? dir.replaceAll("\\", "/") : ""; } +function skillRefreshKey(directory: string): string { + const normalized = normalizeDir(directory); + return process.platform === "win32" ? normalized.toLowerCase() : normalized; +} + /** Compute the display title from a ConversationMeta — render-time priority. */ function getUsableEngineTitle(conv: ConversationMeta): string | undefined { const title = conv.engineTitle?.trim(); @@ -157,6 +162,7 @@ export class EngineManager extends EventEmitter { /** Track active sendMessage call counts per session (for idle/queue detection). */ private activeSessionCounts = new Map(); + private skillRefreshQueues = new Map>(); // --- Adapter Registration --- @@ -775,6 +781,38 @@ export class EngineManager extends EventEmitter { return this.getAdapterOrThrow(engineType).getInfo(); } + async refreshSkillsForDirectory( + directory: string, + engineTypes: EngineType[] = Array.from(this.adapters.keys()), + ): Promise { + const key = skillRefreshKey(directory); + const previous = this.skillRefreshQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const next = previous.catch(() => undefined).then(() => current); + this.skillRefreshQueues.set(key, next); + + await previous.catch(() => undefined); + try { + for (const engineType of [...new Set(engineTypes)]) { + const adapter = this.adapters.get(engineType); + if (!adapter) continue; + try { + await adapter.refreshSkillsForDirectory(directory); + } catch (error) { + engineManagerLog.warn(`Failed to refresh skills for ${engineType} in ${directory}:`, error); + } + } + } finally { + release(); + if (this.skillRefreshQueues.get(key) === next) { + this.skillRefreshQueues.delete(key); + } + } + } + // --- Sessions (backed by ConversationStore) --- async listSessions(engineTypeOrDirectory: string): Promise { diff --git a/electron/main/gateway/ws-server.ts b/electron/main/gateway/ws-server.ts index 87176e10..9aa76780 100644 --- a/electron/main/gateway/ws-server.ts +++ b/electron/main/gateway/ws-server.ts @@ -52,8 +52,13 @@ import { type TerminalListRequest, type TerminalProfilesListRequest, type FileExistsRequest, + type SkillDeleteRequest, + type SkillListRequest, + type SkillRefreshRequest, + type SkillSetEnabledRequest, } from "../../../src/types/unified"; import { isCodexServiceTier, isReasoningEffort } from "../../../src/types/unified"; +import type { SkillApiProvider } from "../services/skill-api-service"; interface ClientConnection { id: string; @@ -65,6 +70,7 @@ export class GatewayServer { private wss: WebSocketServer | null = null; private clients = new Map(); private engineManager: EngineManager; + private skillApi?: SkillApiProvider; private authValidator?: (token: string) => boolean; private pingInterval: ReturnType | null = null; @@ -72,9 +78,11 @@ export class GatewayServer { engineManager: EngineManager, options?: { authValidator?: (token: string) => boolean; + skillApi?: SkillApiProvider; }, ) { this.engineManager = engineManager; + this.skillApi = options?.skillApi; this.authValidator = options?.authValidator; orchestratorService.init(engineManager); this.subscribeToEngineEvents(); @@ -481,6 +489,43 @@ export class GatewayServer { }); } + // Skills + case GatewayRequestType.SKILL_LIST: { + return this.requireSkillApi().listSkills(p as SkillListRequest); + } + + case GatewayRequestType.SKILL_SET_ENABLED: { + const engineTypes = this.getRegisteredEngineTypes(); + const response = await this.requireSkillApi().setSkillEnabled( + p as SkillSetEnabledRequest, + engineTypes, + ); + await this.engineManager.refreshSkillsForDirectory(response.workspaceDirectory, engineTypes); + return response; + } + + case GatewayRequestType.SKILL_DELETE: { + const engineTypes = this.getRegisteredEngineTypes(); + const response = await this.requireSkillApi().deleteSkill( + p as SkillDeleteRequest, + engineTypes, + ); + await this.engineManager.refreshSkillsForDirectory(response.workspaceDirectory, engineTypes); + return response; + } + + case GatewayRequestType.SKILL_REFRESH: { + const request = p as SkillRefreshRequest; + const registeredEngineTypes = this.getRegisteredEngineTypes(); + const targetEngineTypes = request.engineTypes ?? registeredEngineTypes; + const response = await this.requireSkillApi().refreshSkills( + request, + registeredEngineTypes, + ); + await this.engineManager.refreshSkillsForDirectory(response.workspaceDirectory, targetEngineTypes); + return response; + } + // Scheduled Tasks case GatewayRequestType.SCHEDULED_TASK_LIST: return scheduledTaskService.list(); @@ -629,6 +674,17 @@ export class GatewayServer { } } + private requireSkillApi(): SkillApiProvider { + if (!this.skillApi) { + throw Object.assign(new Error("Skill API is not available"), { code: "SKILL_API_UNAVAILABLE" }); + } + return this.skillApi; + } + + private getRegisteredEngineTypes(): EngineType[] { + return this.engineManager.listEngines().map((engine) => engine.type); + } + // --- Notification Broadcasting --- private subscribeToEngineEvents(): void { diff --git a/electron/main/services/app-paths.ts b/electron/main/services/app-paths.ts index 181291af..129e6531 100644 --- a/electron/main/services/app-paths.ts +++ b/electron/main/services/app-paths.ts @@ -53,6 +53,18 @@ export function getConversationsPath(): string { return path.join(getUserDataPath(), "conversations"); } +export function getGlobalSkillsPath(): string { + return path.join(getUserDataPath(), "skills"); +} + +export function getSkillEffectiveRootsPath(): string { + return path.join(getUserDataPath(), "skill-effective-roots"); +} + +export function getSkillProjectionManifestsPath(): string { + return path.join(getUserDataPath(), "skill-projection-manifests"); +} + export function getWorktreesPath(): string { return path.join(getUserDataPath(), "worktrees"); } diff --git a/electron/main/services/logger.ts b/electron/main/services/logger.ts index cd918c8f..235f2883 100644 --- a/electron/main/services/logger.ts +++ b/electron/main/services/logger.ts @@ -146,6 +146,7 @@ export const telegramLog = log.scope("telegram"); export const wecomLog = log.scope("wecom"); export const teamsLog = log.scope("teams"); export const codexLog = log.scope("codex"); +export const skillLog = log.scope("skill"); export const scheduledTaskLog = log.scope("sched-task"); export const terminalLog = log.scope("terminal"); diff --git a/electron/main/services/skill-api-service.ts b/electron/main/services/skill-api-service.ts new file mode 100644 index 00000000..a082024e --- /dev/null +++ b/electron/main/services/skill-api-service.ts @@ -0,0 +1,155 @@ +import type { + EngineType, + SkillDeleteRequest, + SkillDiagnostic, + SkillListRequest, + SkillListResponse, + SkillRefreshRequest, + SkillSetEnabledRequest, +} from "../../../src/types/unified"; +import { + skillRegistryService, + SkillRegistryService, + type SkillConflict, +} from "./skill-registry-service"; +import { + skillProjectionService, + type SkillProjectionProvider, +} from "./skill-projection-service"; + +export interface SkillApiProvider { + listSkills(request: SkillListRequest): Promise; + setSkillEnabled(request: SkillSetEnabledRequest, engineTypes?: EngineType[]): Promise; + deleteSkill(request: SkillDeleteRequest, engineTypes?: EngineType[]): Promise; + refreshSkills(request: SkillRefreshRequest, engineTypes?: EngineType[]): Promise; +} + +export interface SkillApiServiceOptions { + registry?: SkillRegistryService; + projection?: SkillProjectionProvider; +} + +function codedError(code: string, message: string): Error { + return Object.assign(new Error(message), { code }); +} + +function assertWorkspaceDirectory(workspaceDirectory: string | undefined): string { + if (!workspaceDirectory || typeof workspaceDirectory !== "string") { + throw codedError("WORKSPACE_REQUIRED", "workspaceDirectory is required"); + } + return workspaceDirectory; +} + +function isDiscoveryConflict(conflict: SkillConflict): boolean { + return conflict.reason === "discovery-path-conflict"; +} + +export class SkillApiService implements SkillApiProvider { + private readonly registry: SkillRegistryService; + private readonly projection: SkillProjectionProvider; + + constructor(options: SkillApiServiceOptions = {}) { + this.registry = options.registry ?? skillRegistryService; + this.projection = options.projection ?? skillProjectionService; + } + + async listSkills(request: SkillListRequest): Promise { + const workspaceDirectory = assertWorkspaceDirectory(request.workspaceDirectory); + return this.createListResponse(workspaceDirectory, []); + } + + async setSkillEnabled( + request: SkillSetEnabledRequest, + engineTypes: EngineType[] = [], + ): Promise { + const workspaceDirectory = assertWorkspaceDirectory(request.workspaceDirectory); + await this.registry.setSkillEnabled(request.scope, request.name, request.enabled, workspaceDirectory); + return this.refreshSkills({ workspaceDirectory }, engineTypes); + } + + async deleteSkill( + request: SkillDeleteRequest, + engineTypes: EngineType[] = [], + ): Promise { + const workspaceDirectory = assertWorkspaceDirectory(request.workspaceDirectory); + await this.registry.deleteSkill(request.scope, request.name, workspaceDirectory); + return this.refreshSkills({ workspaceDirectory }, engineTypes); + } + + async refreshSkills( + request: SkillRefreshRequest, + engineTypes: EngineType[] = [], + ): Promise { + const workspaceDirectory = assertWorkspaceDirectory(request.workspaceDirectory); + const diagnostics: SkillDiagnostic[] = []; + const requestedEngineTypes = request.engineTypes ?? engineTypes; + const targetEngineTypes = [...new Set(requestedEngineTypes)] + .filter((engineType) => this.projection.getStrategy(engineType) !== "unsupported"); + + if (targetEngineTypes.length === 0) { + const effectiveSet = await this.registry.buildEffectiveSkillSet(workspaceDirectory); + diagnostics.push(...this.diagnosticsFromConflicts(effectiveSet.conflicts)); + return this.createListResponse(workspaceDirectory, diagnostics); + } + + const seenDiagnostics = new Set(); + for (const engineType of targetEngineTypes) { + const result = await this.projection.prepareForEngine(engineType, workspaceDirectory); + for (const diagnostic of this.diagnosticsFromConflicts(result.conflicts, engineType)) { + const key = `${diagnostic.engineType ?? ""}:${diagnostic.skillName ?? ""}:${diagnostic.code}:${JSON.stringify(diagnostic.params ?? {})}`; + if (seenDiagnostics.has(key)) continue; + seenDiagnostics.add(key); + diagnostics.push(diagnostic); + } + } + + return this.createListResponse(workspaceDirectory, diagnostics); + } + + private async createListResponse( + workspaceDirectory: string, + diagnostics: SkillDiagnostic[], + ): Promise { + const snapshot = await this.registry.listSkillSummaries(workspaceDirectory); + return { + ...snapshot, + diagnostics, + }; + } + + private diagnosticsFromConflicts( + conflicts: SkillConflict[], + engineType?: EngineType, + ): SkillDiagnostic[] { + return conflicts.map((conflict): SkillDiagnostic => { + if (isDiscoveryConflict(conflict)) { + return { + severity: "warning", + code: "exposure-conflict", + skillName: conflict.name, + engineType, + params: { + name: conflict.name, + path: conflict.path, + }, + action: { + kind: "open-path", + path: conflict.path, + }, + }; + } + + return { + severity: "error", + code: "engine-exposure-failed", + skillName: conflict.name, + params: { + name: conflict.name, + reason: conflict.reason, + }, + }; + }); + } +} + +export const skillApiService = new SkillApiService(); diff --git a/electron/main/services/skill-projection-service.ts b/electron/main/services/skill-projection-service.ts new file mode 100644 index 00000000..d996f188 --- /dev/null +++ b/electron/main/services/skill-projection-service.ts @@ -0,0 +1,351 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import type { EngineType } from "../../../src/types/unified"; +import { + getSkillProjectionManifestsPath, +} from "./app-paths"; +import { + skillRegistryService, + SkillRegistryService, + type EffectiveSkillSet, + type SkillConflict, +} from "./skill-registry-service"; +import { skillLog, type ScopedLogger } from "./logger"; + +export type SkillLoadStrategy = + | "pass-root-directory" + | "link-into-discovery-dir" + | "unsupported"; + +export interface SkillProjectionResult { + engineType: EngineType; + workspaceDirectory: string; + strategy: SkillLoadStrategy; + effectiveRoot: string | null; + skillDirectories: string[]; + projectedRoot: string | null; + skillNames: string[]; + conflicts: SkillConflict[]; +} + +export interface SkillProjectionProvider { + prepareForEngine(engineType: EngineType, workspaceDirectory: string): Promise; + getStrategy(engineType: EngineType): SkillLoadStrategy; +} + +export interface SkillProjectionServiceOptions { + registry?: SkillRegistryService; + manifestsRoot?: string; + logger?: ScopedLogger; +} + +interface ManagedLinkEntry { + name: string; + linkPath: string; + targetPath: string; +} + +interface ProjectionManifest { + version: 1; + engineType: EngineType; + workspaceDirectory: string; + projectedRoot: string; + entries: ManagedLinkEntry[]; +} + +function normalizePathForCompare(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +function samePath(a: string, b: string): boolean { + return normalizePathForCompare(a) === normalizePathForCompare(b); +} + +function manifestKey(engineType: EngineType, workspaceDirectory: string): string { + const normalized = path.resolve(workspaceDirectory); + const hashPath = process.platform === "win32" ? normalized.toLowerCase() : normalized; + const hash = createHash("sha256").update(`${engineType}:${hashPath}`).digest("hex").slice(0, 16); + return `${engineType}-${hash}.json`; +} + +function linkType(): "dir" | "junction" { + return process.platform === "win32" ? "junction" : "dir"; +} + +function getLoadDirectories(engineType: EngineType, effectiveSet: EffectiveSkillSet): string[] { + switch (engineType) { + case "copilot": + case "codex": + return [effectiveSet.effectiveRoot]; + default: + return []; + } +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readJsonFile(filePath: string): Promise { + try { + const raw = await fs.readFile(filePath, "utf8"); + return JSON.parse(raw) as T; + } catch { + return null; + } +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export class SkillProjectionService implements SkillProjectionProvider { + private readonly registry: SkillRegistryService; + private readonly manifestsRoot: string; + private readonly logger: ScopedLogger; + + constructor(options: SkillProjectionServiceOptions = {}) { + this.registry = options.registry ?? skillRegistryService; + this.manifestsRoot = options.manifestsRoot ?? getSkillProjectionManifestsPath(); + this.logger = options.logger ?? skillLog; + } + + getStrategy(engineType: EngineType): SkillLoadStrategy { + switch (engineType) { + case "copilot": + case "codex": + return "pass-root-directory"; + case "claude": + case "opencode": + return "link-into-discovery-dir"; + default: + return "unsupported"; + } + } + + async prepareForEngine(engineType: EngineType, workspaceDirectory: string): Promise { + const strategy = this.getStrategy(engineType); + if (strategy === "unsupported") { + return { + engineType, + workspaceDirectory: path.resolve(workspaceDirectory), + strategy, + effectiveRoot: null, + skillDirectories: [], + projectedRoot: null, + skillNames: [], + conflicts: [], + }; + } + + const effectiveSet = await this.registry.buildEffectiveSkillSet(workspaceDirectory); + if (strategy === "pass-root-directory") { + return { + engineType, + workspaceDirectory: effectiveSet.workspaceDirectory, + strategy, + effectiveRoot: effectiveSet.effectiveRoot, + skillDirectories: getLoadDirectories(engineType, effectiveSet), + projectedRoot: null, + skillNames: effectiveSet.skills.map((skill) => skill.name), + conflicts: effectiveSet.conflicts, + }; + } + + const projectedRoot = this.getDiscoveryRoot(engineType, effectiveSet.workspaceDirectory); + const bridgeConflicts = await this.projectIntoDiscoveryRoot(engineType, effectiveSet, projectedRoot); + return { + engineType, + workspaceDirectory: effectiveSet.workspaceDirectory, + strategy, + effectiveRoot: effectiveSet.effectiveRoot, + skillDirectories: [], + projectedRoot, + skillNames: effectiveSet.skills.map((skill) => skill.name), + conflicts: [...effectiveSet.conflicts, ...bridgeConflicts], + }; + } + + getDiscoveryRoot(engineType: EngineType, workspaceDirectory: string): string | null { + switch (engineType) { + case "claude": + return path.join(workspaceDirectory, ".claude", "skills"); + case "opencode": + return path.join(workspaceDirectory, ".opencode", "skills"); + default: + return null; + } + } + + private async projectIntoDiscoveryRoot( + engineType: EngineType, + effectiveSet: EffectiveSkillSet, + projectedRoot: string | null, + ): Promise { + if (!projectedRoot) return []; + + await fs.mkdir(projectedRoot, { recursive: true }); + const manifestPath = this.getManifestPath(engineType, effectiveSet.workspaceDirectory); + const previous = await readJsonFile(manifestPath); + const previousEntries = new Map((previous?.entries ?? []).map((entry) => [entry.name, entry])); + const desiredEntries: ManagedLinkEntry[] = effectiveSet.skills.map((skill) => ({ + name: skill.name, + linkPath: path.join(projectedRoot, skill.name), + targetPath: skill.linkPath, + })); + const desiredNames = new Set(desiredEntries.map((entry) => entry.name)); + const conflicts: SkillConflict[] = []; + + for (const entry of previous?.entries ?? []) { + if (desiredNames.has(entry.name)) continue; + await this.removeManagedLink(entry); + } + + for (const entry of desiredEntries) { + const previousEntry = previousEntries.get(entry.name); + const created = await this.ensureManagedLink(entry, previousEntry); + if (!created) { + conflicts.push({ + name: entry.name, + path: entry.linkPath, + reason: "discovery-path-conflict", + }); + } + } + + const manifest: ProjectionManifest = { + version: 1, + engineType, + workspaceDirectory: effectiveSet.workspaceDirectory, + projectedRoot, + entries: desiredEntries.filter((entry) => !conflicts.some((conflict) => samePath(conflict.path, entry.linkPath))), + }; + await this.writeManifest(manifestPath, manifest); + await this.updateGitExclude(engineType, effectiveSet.workspaceDirectory, manifest.entries.map((entry) => entry.linkPath)); + return conflicts; + } + + private async ensureManagedLink(entry: ManagedLinkEntry, previousEntry?: ManagedLinkEntry): Promise { + if (await this.linkPointsTo(entry.linkPath, entry.targetPath)) return true; + + if (previousEntry && await this.linkPointsTo(previousEntry.linkPath, previousEntry.targetPath)) { + await fs.rm(previousEntry.linkPath, { recursive: true, force: true }); + } else if (await pathExists(entry.linkPath)) { + this.logger.warn(`Skill projection conflict at ${entry.linkPath}; leaving existing path untouched.`); + return false; + } + + try { + await fs.symlink(path.resolve(entry.targetPath), entry.linkPath, linkType()); + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to create skill projection ${entry.linkPath}: ${message}`); + return false; + } + } + + private async removeManagedLink(entry: ManagedLinkEntry): Promise { + if (!await this.linkPointsTo(entry.linkPath, entry.targetPath)) return; + await fs.rm(entry.linkPath, { recursive: true, force: true }); + } + + private async linkPointsTo(linkPath: string, targetPath: string): Promise { + try { + const stat = await fs.lstat(linkPath); + if (!stat.isSymbolicLink()) return false; + const rawTarget = await fs.readlink(linkPath); + const resolvedTarget = path.isAbsolute(rawTarget) + ? rawTarget + : path.resolve(path.dirname(linkPath), rawTarget); + return samePath(resolvedTarget, targetPath); + } catch { + return false; + } + } + + private getManifestPath(engineType: EngineType, workspaceDirectory: string): string { + return path.join(this.manifestsRoot, manifestKey(engineType, workspaceDirectory)); + } + + private async writeManifest(manifestPath: string, manifest: ProjectionManifest): Promise { + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + const tmpPath = `${manifestPath}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(manifest, null, 2), "utf8"); + await fs.rename(tmpPath, manifestPath); + } + + private async updateGitExclude(engineType: EngineType, workspaceDirectory: string, linkPaths: string[]): Promise { + const gitContext = await this.resolveGitContext(workspaceDirectory); + if (!gitContext) return; + + const excludePath = path.join(gitContext.infoPath, "exclude"); + const markerId = manifestKey(engineType, workspaceDirectory).replace(/\.json$/, ""); + const beginMarker = `# CodeMux managed skill projections begin ${markerId}`; + const endMarker = `# CodeMux managed skill projections end ${markerId}`; + const patterns = linkPaths + .map((linkPath) => path.relative(gitContext.workTreeRoot, linkPath).replaceAll("\\", "/")) + .filter((relativePath) => relativePath && !relativePath.startsWith("..")) + .map((relativePath) => `/${relativePath}`); + + try { + await fs.mkdir(path.dirname(excludePath), { recursive: true }); + let current = ""; + try { + current = await fs.readFile(excludePath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + + const blockPattern = new RegExp( + `${escapeRegExp(beginMarker)}[\\s\\S]*?${escapeRegExp(endMarker)}\\r?\\n?`, + "g", + ); + const nextBlock = patterns.length > 0 + ? `${beginMarker}\n${patterns.join("\n")}\n${endMarker}\n` + : ""; + const withoutOldBlock = current.replace(blockPattern, ""); + const needsSeparator = withoutOldBlock.length > 0 && !withoutOldBlock.endsWith("\n"); + const next = `${withoutOldBlock}${needsSeparator && nextBlock ? "\n" : ""}${nextBlock}`; + if (next !== current) { + await fs.writeFile(excludePath, next, "utf8"); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to update Git exclude for skill projections in ${workspaceDirectory}: ${message}`); + } + } + + private async resolveGitContext(startDirectory: string): Promise<{ infoPath: string; workTreeRoot: string } | null> { + let current = path.resolve(startDirectory); + while (true) { + const dotGitPath = path.join(current, ".git"); + try { + const stat = await fs.lstat(dotGitPath); + if (stat.isDirectory()) return { infoPath: path.join(dotGitPath, "info"), workTreeRoot: current }; + if (stat.isFile()) { + const raw = await fs.readFile(dotGitPath, "utf8"); + const match = raw.match(/^gitdir:\s*(.+)\s*$/m); + if (!match) return null; + const gitDir = path.isAbsolute(match[1]) ? match[1] : path.resolve(current, match[1]); + return { infoPath: path.join(gitDir, "info"), workTreeRoot: current }; + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return null; + } + + const parent = path.dirname(current); + if (parent === current) return null; + current = parent; + } + } +} + +export const skillProjectionService = new SkillProjectionService(); diff --git a/electron/main/services/skill-registry-service.ts b/electron/main/services/skill-registry-service.ts new file mode 100644 index 00000000..c7394d67 --- /dev/null +++ b/electron/main/services/skill-registry-service.ts @@ -0,0 +1,531 @@ +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { createHash } from "node:crypto"; +import { + getGlobalSkillsPath, + getSkillEffectiveRootsPath, +} from "./app-paths"; +import { loadSettings, saveSettings, skillLog, type ScopedLogger } from "./logger"; +import type { + SkillMutableScope, + SkillScope as UnifiedSkillScope, + SkillScopedInstance, + SkillSummary, +} from "../../../src/types/unified"; + +export type SkillScope = UnifiedSkillScope; + +export interface SkillRecord { + name: string; + scope: SkillScope; + rootPath: string; + skillPath: string; + skillFilePath: string; + description?: string; +} + +export interface EffectiveSkillRecord extends SkillRecord { + linkPath: string; +} + +export interface EffectiveSkillSet { + workspaceDirectory: string; + effectiveRoot: string; + skills: EffectiveSkillRecord[]; + conflicts: SkillConflict[]; +} + +export interface SkillConflict { + name: string; + path: string; + reason: string; +} + +export interface SkillRegistryServiceOptions { + builtinSkillsRoot?: string; + globalSkillsRoot?: string; + effectiveRootsRoot?: string; + loadSettings?: () => Record; + saveSettings?: (patch: Record) => void; + logger?: ScopedLogger; +} + +const PROJECT_SKILLS_RELATIVE_PATH = [".codemux", "skills"]; +const PROJECT_SKILLS_CONFIG_RELATIVE_PATH = [".codemux", "skills.json"]; +const SKILL_FILE_NAME = "SKILL.md"; +const SKILL_SCOPE_PRIORITY: Record = { + builtin: 0, + global: 1, + project: 2, +}; +const SKILL_SCOPE_DISPLAY_ORDER: SkillScope[] = ["project", "global", "builtin"]; + +function workspaceKey(directory: string): string { + const normalized = path.resolve(directory); + const hashInput = process.platform === "win32" ? normalized.toLowerCase() : normalized; + const hash = createHash("sha256").update(hashInput).digest("hex").slice(0, 12); + const baseName = path.basename(normalized).replace(/[^a-zA-Z0-9._-]/g, "-") || "workspace"; + return `${baseName}-${hash}`; +} + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function addDisabledSkillNames(value: unknown, disabled: Set): void { + if (!Array.isArray(value)) return; + for (const name of value) { + if (typeof name === "string" && name.length > 0) { + disabled.add(name); + } + } +} + +function readDisabledSkills(settings: Record): Set { + const skillsSettings = isRecord(settings.skills) ? settings.skills : {}; + const disabled = new Set(); + addDisabledSkillNames(settings.disabled, disabled); + addDisabledSkillNames(settings.disabledSkills, disabled); + addDisabledSkillNames(skillsSettings.disabled, disabled); + addDisabledSkillNames(skillsSettings.disabledSkills, disabled); + return disabled; +} + +function sortedSkillNames(names: Iterable): string[] { + return [...names].sort((a, b) => a.localeCompare(b)); +} + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function readJsonFile(filePath: string, logger: ScopedLogger): Promise> { + try { + const raw = await fs.readFile(filePath, "utf8"); + const parsed = JSON.parse(raw) as unknown; + return isRecord(parsed) ? parsed : {}; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to read skill config ${filePath}: ${message}`); + } + return {}; + } +} + +async function writeJsonFile(filePath: string, value: Record): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + const tmpPath = `${filePath}.tmp`; + await fs.writeFile(tmpPath, JSON.stringify(value, null, 2), "utf8"); + await fs.rename(tmpPath, filePath); +} + +function parseYamlScalar(value: string): string { + const trimmed = value.trim(); + if ((trimmed.startsWith("\"") && trimmed.endsWith("\"")) + || (trimmed.startsWith("'") && trimmed.endsWith("'"))) { + return trimmed.slice(1, -1); + } + return trimmed; +} + +async function readSkillDescription(skillFilePath: string, logger: ScopedLogger): Promise { + try { + const raw = await fs.readFile(skillFilePath, "utf8"); + const frontmatter = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/); + const description = frontmatter?.[1].match(/^description:\s*(.+)$/m); + return description ? parseYamlScalar(description[1]) : undefined; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Failed to read skill description ${skillFilePath}: ${message}`); + return undefined; + } +} + +function linkType(): "dir" | "junction" { + return process.platform === "win32" ? "junction" : "dir"; +} + +function normalizePathForCompare(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +export class SkillRegistryService { + private readonly builtinSkillsRoot: string; + private readonly globalSkillsRoot: string; + private readonly effectiveRootsRoot: string; + private readonly settingsLoader: () => Record; + private readonly settingsSaver: (patch: Record) => void; + private readonly logger: ScopedLogger; + private readonly effectiveBuildQueues = new Map>(); + + constructor(options: SkillRegistryServiceOptions = {}) { + this.builtinSkillsRoot = options.builtinSkillsRoot ?? path.join(process.resourcesPath ?? process.cwd(), "skills"); + this.globalSkillsRoot = options.globalSkillsRoot ?? getGlobalSkillsPath(); + this.effectiveRootsRoot = options.effectiveRootsRoot ?? getSkillEffectiveRootsPath(); + this.settingsLoader = options.loadSettings ?? loadSettings; + this.settingsSaver = options.saveSettings ?? saveSettings; + this.logger = options.logger ?? skillLog; + } + + getRootPath(scope: SkillScope, workspaceDirectory?: string): string { + switch (scope) { + case "builtin": + return this.builtinSkillsRoot; + case "global": + return this.globalSkillsRoot; + case "project": + if (!workspaceDirectory) { + throw new Error("workspaceDirectory is required for project-scoped skills"); + } + return path.join(workspaceDirectory, ...PROJECT_SKILLS_RELATIVE_PATH); + } + } + + getEffectiveRoot(workspaceDirectory: string): string { + return path.join(this.effectiveRootsRoot, workspaceKey(workspaceDirectory)); + } + + async listSkillRoots(workspaceDirectory: string): Promise> { + return [ + { scope: "builtin", path: this.builtinSkillsRoot }, + { scope: "global", path: this.globalSkillsRoot }, + { scope: "project", path: this.getRootPath("project", workspaceDirectory) }, + ]; + } + + async listSkills(workspaceDirectory: string): Promise { + const roots = await this.listSkillRoots(workspaceDirectory); + const skills: SkillRecord[] = []; + for (const root of roots) { + skills.push(...await this.scanRoot(root.scope, root.path)); + } + return skills; + } + + async buildEffectiveSkillSet(workspaceDirectory: string): Promise { + const resolvedWorkspace = path.resolve(workspaceDirectory); + return this.withEffectiveBuildQueue(resolvedWorkspace, () => this.buildEffectiveSkillSetUnlocked(resolvedWorkspace)); + } + + private async withEffectiveBuildQueue(workspaceDirectory: string, build: () => Promise): Promise { + const key = workspaceKey(workspaceDirectory); + const previous = this.effectiveBuildQueues.get(key) ?? Promise.resolve(); + let release!: () => void; + const current = new Promise((resolve) => { + release = resolve; + }); + const next = previous.catch(() => undefined).then(() => current); + this.effectiveBuildQueues.set(key, next); + + await previous.catch(() => undefined); + try { + return await build(); + } finally { + release(); + if (this.effectiveBuildQueues.get(key) === next) { + this.effectiveBuildQueues.delete(key); + } + } + } + + private async buildEffectiveSkillSetUnlocked(resolvedWorkspace: string): Promise { + const disabled = await this.loadDisabledSkillSets(resolvedWorkspace); + + const allSkills = await this.listSkills(resolvedWorkspace); + const selected = this.selectEffectiveSkills(allSkills, disabled); + const effectiveRoot = this.getEffectiveRoot(resolvedWorkspace); + const conflicts: SkillConflict[] = []; + + await fs.mkdir(effectiveRoot, { recursive: true }); + await fs.writeFile(path.join(effectiveRoot, ".codemux-managed"), "This directory is managed by CodeMux.\n", "utf8"); + + const effectiveSkills: EffectiveSkillRecord[] = []; + const selectedNames = new Set(selected.map((skill) => skill.name)); + for (const skill of selected) { + const linkPath = path.join(effectiveRoot, skill.name); + try { + await this.ensureEffectiveLink(skill, linkPath); + effectiveSkills.push({ ...skill, linkPath }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to expose skill ${skill.name} in effective root: ${message}`); + conflicts.push({ name: skill.name, path: linkPath, reason: `link-failed: ${message}` }); + } + } + await this.removeStaleEffectiveLinks(effectiveRoot, selectedNames); + + return { + workspaceDirectory: resolvedWorkspace, + effectiveRoot, + skills: effectiveSkills, + conflicts, + }; + } + + private async ensureEffectiveLink(skill: SkillRecord, linkPath: string): Promise { + const targetPath = path.resolve(skill.skillPath); + const existingTarget = await this.readLinkTarget(linkPath); + if (existingTarget && normalizePathForCompare(existingTarget) === normalizePathForCompare(targetPath)) { + return; + } + + await fs.rm(linkPath, { recursive: true, force: true }); + await fs.symlink(targetPath, linkPath, linkType()); + } + + private async readLinkTarget(linkPath: string): Promise { + try { + const stat = await fs.lstat(linkPath); + if (!stat.isSymbolicLink()) return null; + const rawTarget = await fs.readlink(linkPath); + return path.isAbsolute(rawTarget) ? rawTarget : path.resolve(path.dirname(linkPath), rawTarget); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; + throw error; + } + } + + private async removeStaleEffectiveLinks(effectiveRoot: string, selectedNames: Set): Promise { + const entries = await fs.readdir(effectiveRoot, { withFileTypes: true }); + await Promise.all(entries.map(async (entry) => { + if (entry.name === ".codemux-managed" || selectedNames.has(entry.name)) return; + await fs.rm(path.join(effectiveRoot, entry.name), { recursive: true, force: true }); + })); + } + + async deleteSkill(scope: SkillScope, name: string, workspaceDirectory?: string): Promise { + this.validateSkillName(name); + if (scope === "builtin") { + throw Object.assign(new Error("Builtin skills cannot be deleted"), { code: "BUILTIN_SKILL_READONLY" }); + } + if (scope === "project" && !workspaceDirectory) { + throw new Error("workspaceDirectory is required for project-scoped skills"); + } + const root = this.getRootPath(scope, workspaceDirectory); + await fs.rm(path.join(root, name), { recursive: true, force: true }); + } + + async setSkillEnabled( + scope: SkillMutableScope, + name: string, + enabled: boolean, + workspaceDirectory?: string, + ): Promise { + this.validateSkillName(name); + if (scope !== "global" && scope !== "project") { + throw Object.assign(new Error(`Invalid mutable skill scope: ${scope}`), { code: "INVALID_SKILL_SCOPE" }); + } + if (scope === "project" && !workspaceDirectory) { + throw new Error("workspaceDirectory is required for project skill settings"); + } + + if (scope === "global") { + const settings = this.settingsLoader(); + const disabled = readDisabledSkills(settings); + if (enabled) { + disabled.delete(name); + } else { + disabled.add(name); + } + const disabledNames = sortedSkillNames(disabled); + this.settingsSaver({ + disabled: disabledNames, + disabledSkills: disabledNames, + skills: { + disabled: disabledNames, + disabledSkills: disabledNames, + }, + }); + return; + } + + const configPath = path.join(path.resolve(workspaceDirectory!), ...PROJECT_SKILLS_CONFIG_RELATIVE_PATH); + const config = await readJsonFile(configPath, this.logger); + const disabled = readDisabledSkills(config); + if (enabled) { + disabled.delete(name); + } else { + disabled.add(name); + } + const disabledNames = sortedSkillNames(disabled); + await writeJsonFile(configPath, { + ...config, + disabled: disabledNames, + disabledSkills: disabledNames, + skills: { + ...(isRecord(config.skills) ? config.skills : {}), + disabled: disabledNames, + disabledSkills: disabledNames, + }, + }); + } + + async listSkillSummaries(workspaceDirectory: string): Promise<{ + workspaceDirectory: string; + effectiveRoot: string; + skills: SkillSummary[]; + }> { + const resolvedWorkspace = path.resolve(workspaceDirectory); + const disabled = await this.loadDisabledSkillSets(resolvedWorkspace); + const allSkills = await this.listSkills(resolvedWorkspace); + const selectedByName = new Map( + this.selectEffectiveSkills(allSkills, disabled).map((skill) => [skill.name, skill]), + ); + const skillsByName = new Map(); + for (const skill of allSkills) { + const existing = skillsByName.get(skill.name) ?? []; + existing.push(skill); + skillsByName.set(skill.name, existing); + } + + const skills = [...skillsByName.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([name, records]) => { + records.sort((a, b) => SKILL_SCOPE_DISPLAY_ORDER.indexOf(a.scope) - SKILL_SCOPE_DISPLAY_ORDER.indexOf(b.scope)); + const effective = selectedByName.get(name); + const disabledAt = [ + disabled.project.has(name) ? { scope: "project" as const } : undefined, + disabled.global.has(name) ? { scope: "global" as const } : undefined, + ].filter((entry): entry is { scope: SkillMutableScope } => !!entry); + const scopes: SkillScopedInstance[] = records.map((record) => { + const instance: SkillScopedInstance = { + scope: record.scope, + description: record.description, + path: record.skillPath, + }; + if (effective && record.scope === effective.scope) { + const shadows = records + .filter((candidate) => + !this.isDisabledSkillRecord(candidate, disabled) + && SKILL_SCOPE_PRIORITY[candidate.scope] < SKILL_SCOPE_PRIORITY[record.scope]) + .map((candidate) => candidate.scope); + if (shadows.length > 0) { + instance.shadows = shadows; + } + } else if ( + effective + && !this.isDisabledSkillRecord(record, disabled) + && SKILL_SCOPE_PRIORITY[effective.scope] > SKILL_SCOPE_PRIORITY[record.scope] + ) { + instance.shadowedBy = effective.scope; + } + return instance; + }); + + const summary: SkillSummary = { + name, + description: effective?.description ?? records.find((record) => record.description)?.description, + enabled: !!effective, + effectiveScope: effective?.scope ?? null, + scopes, + }; + if (disabledAt.length > 0) { + summary.disabledAt = disabledAt; + } + return summary; + }); + + return { + workspaceDirectory: resolvedWorkspace, + effectiveRoot: this.getEffectiveRoot(resolvedWorkspace), + skills, + }; + } + + private async scanRoot(scope: SkillScope, rootPath: string): Promise { + if (!await pathExists(rootPath)) return []; + + let entries: import("node:fs").Dirent[]; + try { + entries = await fs.readdir(rootPath, { withFileTypes: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.logger.warn(`Failed to scan skill root ${rootPath}: ${message}`); + return []; + } + + const skills: SkillRecord[] = []; + for (const entry of entries) { + if (!entry.isDirectory() && !entry.isSymbolicLink()) continue; + const name = entry.name; + if (!this.isValidSkillName(name)) { + this.logger.warn(`Ignoring invalid skill directory name ${name} in ${rootPath}`); + continue; + } + + const skillPath = path.join(rootPath, name); + const skillFilePath = path.join(skillPath, SKILL_FILE_NAME); + if (!await pathExists(skillFilePath)) continue; + + skills.push({ + name, + scope, + rootPath, + skillPath, + skillFilePath, + description: await readSkillDescription(skillFilePath, this.logger), + }); + } + return skills; + } + + private selectEffectiveSkills( + skills: SkillRecord[], + disabled: { global: Set; project: Set }, + ): SkillRecord[] { + const selected = new Map(); + for (const skill of skills) { + if (this.isDisabledSkillRecord(skill, disabled)) continue; + const existing = selected.get(skill.name); + if (!existing || SKILL_SCOPE_PRIORITY[skill.scope] > SKILL_SCOPE_PRIORITY[existing.scope]) { + selected.set(skill.name, skill); + } + } + return [...selected.values()].sort((a, b) => a.name.localeCompare(b.name)); + } + + private isDisabledSkillRecord( + skill: SkillRecord, + disabled: { global: Set; project: Set }, + ): boolean { + if (skill.scope === "project") { + return disabled.project.has(skill.name); + } + if (skill.scope === "global") { + return disabled.global.has(skill.name); + } + return false; + } + + private async loadDisabledSkillSets(workspaceDirectory: string): Promise<{ + global: Set; + project: Set; + }> { + const global = readDisabledSkills(this.settingsLoader()); + const projectSettings = await readJsonFile(path.join(workspaceDirectory, ...PROJECT_SKILLS_CONFIG_RELATIVE_PATH), this.logger); + const project = readDisabledSkills(projectSettings); + return { + global, + project, + }; + } + + private validateSkillName(name: string): void { + if (!this.isValidSkillName(name)) { + throw new Error(`Invalid skill name: ${name}`); + } + } + + private isValidSkillName(name: string): boolean { + return /^[a-zA-Z0-9._-]+$/.test(name) && name !== "." && name !== ".."; + } +} + +export const skillRegistryService = new SkillRegistryService(); diff --git a/scripts/ci-install.ts b/scripts/ci-install.ts new file mode 100644 index 00000000..0c094a5d --- /dev/null +++ b/scripts/ci-install.ts @@ -0,0 +1,28 @@ +import { spawnSync } from "node:child_process"; + +const maxAttempts = Number.parseInt(process.env.CI_BUN_INSTALL_ATTEMPTS ?? "3", 10); +const bunCommand = "bun"; + +function runBun(args: string[]): number { + const result = spawnSync(bunCommand, args, { + stdio: "inherit", + shell: process.platform === "win32", + }); + return result.status ?? 1; +} + +for (let attempt = 1; attempt <= maxAttempts; attempt++) { + console.log(`[ci-install] bun install attempt ${attempt}/${maxAttempts}`); + const status = runBun(["install", "--frozen-lockfile"]); + if (status === 0) { + process.exit(0); + } + + if (attempt === maxAttempts) { + process.exit(status); + } + + console.warn(`[ci-install] bun install failed with exit code ${status}; clearing Bun cache before retry`); + runBun(["pm", "cache", "rm"]); + await new Promise((resolve) => setTimeout(resolve, attempt * 5_000)); +} diff --git a/scripts/ensure-electron.ts b/scripts/ensure-electron.ts new file mode 100644 index 00000000..7fbeb4c2 --- /dev/null +++ b/scripts/ensure-electron.ts @@ -0,0 +1,39 @@ +import { spawnSync } from "node:child_process"; +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +function resolveElectron(): string | null { + try { + return require("electron") as string; + } catch { + return null; + } +} + +function runElectronInstall(): void { + const installScript = require.resolve("electron/install.js"); + const result = spawnSync(process.execPath, [installScript], { + stdio: "inherit", + env: { + ...process.env, + ELECTRON_SKIP_BINARY_DOWNLOAD: "", + }, + }); + + if (result.status !== 0) { + throw new Error(`electron/install.js failed with exit code ${result.status ?? "unknown"}`); + } +} + +if (!resolveElectron()) { + console.log("[ensure-electron] Electron binary missing; running electron/install.js"); + runElectronInstall(); +} + +const electronPath = resolveElectron(); +if (!electronPath) { + throw new Error("Electron failed to install correctly after repair"); +} + +console.log(`[ensure-electron] Electron binary ready: ${electronPath}`); diff --git a/src/App.tsx b/src/App.tsx index e6fd5433..fb403847 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,9 +20,11 @@ const PageFallback = () => ( const SettingsLazy = lazy(() => import("./pages/Settings")); const DevicesLazy = lazy(() => import("./pages/Devices")); +const SkillsLazy = lazy(() => import("./pages/Skills")); const Settings = () => }>; const Devices = () => }>; +const Skills = () => }>; // Use HashRouter for Electron (file:// protocol) and regular Router for web // HashRouter uses URL hashes (#/path) which work with file:// protocol @@ -243,6 +245,7 @@ function App() { + diff --git a/src/components/SkillSettingsSection.tsx b/src/components/SkillSettingsSection.tsx new file mode 100644 index 00000000..0746af90 --- /dev/null +++ b/src/components/SkillSettingsSection.tsx @@ -0,0 +1,870 @@ +import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"; +import { formatMessage, useI18n } from "../lib/i18n"; +import { ensureGatewayInitialized } from "../lib/engine-bootstrap"; +import { gateway } from "../lib/gateway-api"; +import { systemAPI } from "../lib/electron-api"; +import { getProjectName, sessionStore } from "../stores/session"; +import type { + SkillDiagnostic, + SkillListResponse, + SkillMutableScope, + SkillScope, + SkillScopedInstance, + SkillSummary, + UnifiedProject, +} from "../types/unified"; +import { Spinner } from "./Spinner"; + +interface WorkspaceOption { + directory: string; + label: string; +} + +interface SelectedSkillReference { + name: string; + scope: SkillScope; +} + +interface ScopeGroup { + scope: SkillScope; + title: string; + description: string; + skills: Array<{ + skill: SkillSummary; + instance: SkillScopedInstance; + }>; +} + +type SkillScopeStatus = "effective" | "disabled" | "overridden"; + +function scopeBadgeClass(scope: SkillScope): string { + switch (scope) { + case "project": + return "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300"; + case "global": + return "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300"; + case "builtin": + return "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300"; + } +} + +function scopeCardClass(scope: SkillScope): string { + switch (scope) { + case "project": + return "border-blue-200 hover:border-blue-300 hover:bg-blue-50/60 dark:border-blue-900/50 dark:hover:border-blue-700 dark:hover:bg-blue-900/10"; + case "global": + return "border-purple-200 hover:border-purple-300 hover:bg-purple-50/60 dark:border-purple-900/50 dark:hover:border-purple-700 dark:hover:bg-purple-900/10"; + case "builtin": + return "border-slate-200 hover:border-slate-300 hover:bg-slate-50 dark:border-slate-700 dark:hover:border-slate-600 dark:hover:bg-slate-700/30"; + } +} + +function isMutableScope(scope: SkillScope): scope is SkillMutableScope { + return scope === "project" || scope === "global"; +} + +function diagnosticClass(diagnostic: SkillDiagnostic): string { + if (diagnostic.severity === "error") { + return "border-red-200 dark:border-red-900/40 bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"; + } + if (diagnostic.severity === "warning") { + return "border-amber-200 dark:border-amber-900/40 bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300"; + } + return "border-blue-200 dark:border-blue-900/40 bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"; +} + +function getDisabledAt(skill: SkillSummary, scope: SkillMutableScope): boolean { + return skill.disabledAt?.some((entry) => entry.scope === scope) ?? false; +} + +export function SkillSettingsSection() { + const { t } = useI18n(); + const [workspaceProjects, setWorkspaceProjects] = createSignal([]); + const [selectedWorkspace, setSelectedWorkspace] = createSignal(""); + const [searchQuery, setSearchQuery] = createSignal(""); + const [loadingWorkspaces, setLoadingWorkspaces] = createSignal(true); + const [workspaceError, setWorkspaceError] = createSignal(null); + const [actionLoading, setActionLoading] = createSignal(null); + const [actionError, setActionError] = createSignal(null); + const [selectedSkillRef, setSelectedSkillRef] = createSignal(null); + + const formatDiagnosticMessage = (diagnostic: SkillDiagnostic): string => { + const params = diagnostic.params ?? {}; + const values = { + name: params.name ?? diagnostic.skillName ?? "", + path: params.path ?? "", + reason: params.reason ?? diagnostic.code, + scope: params.scope ?? "", + }; + switch (diagnostic.code) { + case "exposure-conflict": + return formatMessage(t().skill.diagnosticExposureConflict, values); + case "engine-exposure-failed": + return formatMessage(t().skill.diagnosticEngineExposureFailed, values); + case "invalid-skill": + return formatMessage(t().skill.diagnosticInvalidSkill, values); + case "skill-shadowed": + return formatMessage(t().skill.diagnosticSkillShadowed, values); + } + }; + + const currentSessionDirectory = createMemo(() => { + const currentId = sessionStore.current; + return sessionStore.list.find((session) => session.id === currentId)?.directory; + }); + + const workspaceOptions = createMemo(() => { + const byDirectory = new Map(); + for (const project of [...sessionStore.projects, ...workspaceProjects()]) { + if (!project.directory) continue; + byDirectory.set(project.directory, { + directory: project.directory, + label: project.isDefault ? `${getProjectName(project)} (${t().skill.defaultWorkspace})` : getProjectName(project), + }); + } + + const currentDirectory = currentSessionDirectory(); + if (currentDirectory && !byDirectory.has(currentDirectory)) { + byDirectory.set(currentDirectory, { + directory: currentDirectory, + label: t().skill.currentSessionWorkspace, + }); + } + + return [...byDirectory.values()].sort((a, b) => a.label.localeCompare(b.label)); + }); + + const fetchSkillResponse = async (workspaceDirectory: string) => { + if (!workspaceDirectory) return null; + setActionError(null); + await ensureGatewayInitialized(); + return gateway.listSkills(workspaceDirectory); + }; + + const [skillResponse, { mutate: setSkillResponse }] = createResource( + selectedWorkspace, + fetchSkillResponse, + ); + + onMount(async () => { + setLoadingWorkspaces(true); + setWorkspaceError(null); + try { + await ensureGatewayInitialized(); + const projects = await gateway.listAllProjects(); + setWorkspaceProjects(projects); + } catch (error) { + setWorkspaceError(error instanceof Error ? error.message : String(error)); + } finally { + setLoadingWorkspaces(false); + } + }); + + const handleEscapeKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && selectedSkillRef()) { + setSelectedSkillRef(null); + } + }; + + onMount(() => { + window.addEventListener("keydown", handleEscapeKey); + }); + + onCleanup(() => { + window.removeEventListener("keydown", handleEscapeKey); + }); + + createEffect(() => { + const options = workspaceOptions(); + if (options.length === 0) return; + + const currentDirectory = currentSessionDirectory(); + const defaultProject = [...sessionStore.projects, ...workspaceProjects()].find((project) => project.isDefault); + const preferred = + options.find((option) => option.directory === currentDirectory) + ?? options.find((option) => option.directory === defaultProject?.directory) + ?? options[0]; + if (!selectedWorkspace()) { + setSelectedWorkspace(preferred.directory); + } + }); + + const runAction = async ( + key: string, + action: () => Promise, + ) => { + setActionLoading(key); + setActionError(null); + try { + await action(); + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } finally { + setActionLoading(null); + } + }; + + const updateSkillResponsesAfterMutation = async ( + workspaceDirectory: string, + response: SkillListResponse, + ) => { + const visibleWorkspace = selectedWorkspace(); + if (!visibleWorkspace) return; + const visibleResponse = visibleWorkspace === workspaceDirectory + ? response + : await gateway.listSkills(visibleWorkspace); + setSkillResponse(visibleResponse); + }; + + const handleRefresh = () => { + const workspaceDirectory = selectedWorkspace(); + if (!workspaceDirectory) return; + void runAction("refresh", async () => { + const response = await gateway.refreshSkills(workspaceDirectory); + setSkillResponse(response); + }); + }; + + const handleSetEnabled = ( + skillName: string, + scope: SkillMutableScope, + enabled: boolean, + ) => { + const workspaceDirectory = selectedWorkspace(); + if (!workspaceDirectory) return; + void runAction( + `${scope}:${skillName}:${enabled ? "enable" : "disable"}`, + async () => { + const response = await gateway.setSkillEnabled(workspaceDirectory, skillName, scope, enabled); + await updateSkillResponsesAfterMutation(workspaceDirectory, response); + }, + ); + }; + + const handleDelete = (skill: SkillSummary, scope: SkillMutableScope) => { + const workspaceDirectory = selectedWorkspace(); + if (!workspaceDirectory) return; + if (!confirm(formatMessage(t().skill.deleteConfirm, { + name: `${skill.name} (${scopeLabel(scope)})`, + }))) { + return; + } + void runAction( + `delete:${scope}:${skill.name}`, + async () => { + const response = await gateway.deleteSkill(workspaceDirectory, skill.name, scope); + await updateSkillResponsesAfterMutation(workspaceDirectory, response); + }, + ); + }; + + const handleOpenPath = async (filePath?: string) => { + if (!filePath) return; + setActionError(null); + try { + const openError = await systemAPI.openPath(filePath); + if (openError) { + setActionError(openError); + } + } catch (error) { + setActionError(error instanceof Error ? error.message : String(error)); + } + }; + + const handleOpenDiagnosticAction = async (diagnostic: SkillDiagnostic) => { + await handleOpenPath(diagnostic.action?.path); + }; + + const scopeLabel = (scope: SkillScope): string => { + switch (scope) { + case "project": + return t().skill.scopeProject; + case "global": + return t().skill.scopeGlobal; + case "builtin": + return t().skill.scopeBuiltin; + } + }; + + const disabledAtScopeText = (scope: SkillMutableScope): string => + formatMessage(t().skill.disabledAt, { scopes: scopeLabel(scope) }); + + const isScopeDisabled = (skill: SkillSummary, scope: SkillScope): boolean => + isMutableScope(scope) && getDisabledAt(skill, scope); + + const scopeStatus = (skill: SkillSummary, scope: SkillScope): SkillScopeStatus => { + if (isScopeDisabled(skill, scope)) { + return "disabled"; + } + if (skill.effectiveScope === scope) { + return "effective"; + } + return "overridden"; + }; + + const scopeStatusText = (skill: SkillSummary, scope: SkillScope): string => { + switch (scopeStatus(skill, scope)) { + case "effective": + return t().skill.effective; + case "disabled": + return t().skill.disabled; + case "overridden": + return t().skill.overridden; + } + }; + + const scopeStatusTitle = (skill: SkillSummary, scope: SkillScope): string => { + if (isMutableScope(scope) && getDisabledAt(skill, scope)) { + return disabledAtScopeText(scope); + } + return scopeStatusText(skill, scope); + }; + + const scopeStatusBadgeClass = (skill: SkillSummary, scope: SkillScope): string => { + switch (scopeStatus(skill, scope)) { + case "effective": + return "bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"; + case "disabled": + return "bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300"; + case "overridden": + return "bg-slate-100 text-slate-600 dark:bg-slate-700 dark:text-slate-300"; + } + }; + + const scopeToggleLabel = (skill: SkillSummary, scope: SkillMutableScope): string => { + return getDisabledAt(skill, scope) ? t().skill.enableSkill : t().skill.disableSkill; + }; + + const scopeToggleTitle = (skill: SkillSummary, scope: SkillMutableScope): string => { + if (scope === "project") { + return getDisabledAt(skill, "project") ? t().skill.enableForProject : t().skill.disableForProject; + } + return getDisabledAt(skill, "global") ? t().skill.enableGlobally : t().skill.disableGlobally; + }; + + const matchesSearch = (skill: SkillSummary): boolean => { + const query = searchQuery().trim().toLowerCase(); + if (!query) return true; + return ( + skill.name.toLowerCase().includes(query) + || (skill.description ?? "").toLowerCase().includes(query) + || skill.scopes.some((instance) => scopeLabel(instance.scope).toLowerCase().includes(query)) + ); + }; + + const visibleSkills = createMemo(() => (skillResponse()?.skills ?? []).filter(matchesSearch)); + + const scopeDefinitions = createMemo(() => [ + { + scope: "global" as const, + title: scopeLabel("global"), + description: t().skill.scopeGlobalDesc, + }, + { + scope: "project" as const, + title: scopeLabel("project"), + description: t().skill.scopeProjectDesc, + }, + { + scope: "builtin" as const, + title: scopeLabel("builtin"), + description: t().skill.scopeBuiltinDesc, + }, + ]); + + const groupedSkills = createMemo(() => + scopeDefinitions().map((definition) => ({ + ...definition, + skills: visibleSkills() + .map((skill) => ({ + skill, + instance: skill.scopes.find((instance) => instance.scope === definition.scope), + })) + .filter((entry): entry is { skill: SkillSummary; instance: SkillScopedInstance } => !!entry.instance), + })), + ); + + const selectedSkill = createMemo(() => { + const selected = selectedSkillRef(); + if (!selected) return null; + return skillResponse()?.skills.find((skill) => skill.name === selected.name) ?? null; + }); + + const selectedInstance = createMemo(() => { + const skill = selectedSkill(); + const selected = selectedSkillRef(); + if (!skill || !selected) return null; + return skill.scopes.find((instance) => instance.scope === selected.scope) ?? null; + }); + + const selectedOtherScopes = createMemo(() => { + const skill = selectedSkill(); + const selected = selectedSkillRef(); + if (!skill || !selected) return []; + return skill.scopes.filter((instance) => instance.scope !== selected.scope); + }); + + const selectedMutableScope = createMemo(() => { + const scope = selectedInstance()?.scope; + return scope && isMutableScope(scope) ? scope : null; + }); + + const selectedDiagnostics = createMemo(() => { + const selected = selectedSkillRef(); + const skill = selectedSkill(); + if (!selected || !skill) return []; + return (skillResponse()?.diagnostics ?? []).filter((diagnostic) => diagnostic.skillName === skill.name); + }); + + const selectedHasBodyContent = createMemo(() => + selectedOtherScopes().length > 0 || selectedDiagnostics().length > 0, + ); + + const diagnostics = createMemo(() => skillResponse()?.diagnostics ?? []); + + const skillsLoading = createMemo(() => skillResponse.loading); + const hasVisibleSkills = createMemo(() => groupedSkills().some((group) => group.skills.length > 0)); + + const selectedProjectWorkspaceLabel = createMemo(() => + workspaceOptions().find((workspace) => workspace.directory === selectedWorkspace())?.label ?? t().skill.currentSessionWorkspace, + ); + + createEffect(() => { + const selected = selectedSkillRef(); + const response = skillResponse(); + if (selected && response && !response.skills.some((skill) => skill.name === selected.name)) { + setSelectedSkillRef(null); + } + }); + + return ( +
+
+
+

+ {t().skill.title} +

+

+ {t().skill.description} +

+
+
+ setSearchQuery(event.currentTarget.value)} + placeholder={t().skill.searchPlaceholder} + class="w-full sm:w-[240px] px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> + +
+
+ +
+ +
+

{workspaceError()}

+
+
+ + +
+

{actionError()}

+
+
+ + 0}> +
+ + {(diagnostic) => ( +
+
+

+ {t().skill.diagnostic}: + {formatDiagnosticMessage(diagnostic)} +

+ + + +
+
+ )} +
+
+
+ + + + {t().common.loading} +
+ } + > + + {selectedWorkspace() + ? searchQuery().trim() + ? t().skill.noSearchResults + : t().skill.empty + : t().skill.noWorkspace} + + } + > +
+ + {(group) => ( +
+
+
+

+ {group.title} +

+

+ {group.description} +

+
+ + {group.skills.length} + +
+ + +
+ 0} + fallback={ +
+ +
+ + {t().common.loading} +
+
+
+ } + > + + +

+ {selectedProjectWorkspaceLabel()} +

+
+
+
+
+ +
+ 0} + fallback={ +
+ {t().skill.noSkillsInScope} +
+ } + > + + {({ skill, instance }) => { + const status = () => scopeStatus(skill, instance.scope); + return ( +
setSelectedSkillRef({ name: skill.name, scope: instance.scope })} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedSkillRef({ name: skill.name, scope: instance.scope }); + } + }} + class={`text-left rounded-xl border bg-white dark:bg-slate-800 p-4 transition-colors ${scopeCardClass(instance.scope)}`} + > +
+
+

+ {skill.name} +

+

+ {instance.description || skill.description || t().skill.noDescription} +

+
+ +
+ +
+ + {scopeStatusText(skill, instance.scope)} + + + {scopeLabel(instance.scope)} + +
+ +
+ + {t().skill.cardDetails} + +
+ + {(scope) => ( + + )} + +
+
+
+ ); + }} +
+
+
+
+ )} +
+
+
+ + + + + {(skill) => ( +
setSelectedSkillRef(null)} + > +
event.stopPropagation()} + > +
+
+
+

+ {t().skill.detailsTitle} +

+

+ {skill.name} +

+

+ {skill.description || t().skill.noDescription} +

+ + + {(sourcePath) => ( +
+

+ {t().skill.sourcePath} +

+ +
+ )} +
+ +
+ + {(instance) => ( + + {scopeLabel(instance().scope)} + + )} + + + {(instance) => ( + + {scopeStatusText(skill, instance().scope)} + + )} + +
+
+ + +
+ + {(scope) => ( +
+ + +
+ )} +
+
+ + +
+ 0}> +
+

+ {t().skill.otherScopes} +

+
+ + {(instance) => ( +
+
+ + {scopeLabel(instance.scope)} + + + {scopeStatusText(skill, instance.scope)} + +
+

+ {instance.description || skill.description || t().skill.noDescription} +

+ + {(sourcePath) => ( + + )} + +
+ )} +
+
+
+
+ + 0}> +
+

+ {t().skill.diagnostic} +

+
+ + {(diagnostic) => ( +
+
+

{formatDiagnosticMessage(diagnostic)}

+ + + +
+
+ )} +
+
+
+
+
+
+
+
+ )} +
+
+ ); +} diff --git a/src/lib/gateway-api.ts b/src/lib/gateway-api.ts index a57d98ee..cf620502 100644 --- a/src/lib/gateway-api.ts +++ b/src/lib/gateway-api.ts @@ -43,6 +43,8 @@ import type { TerminalListResponse, TerminalProfilesListResponse, FileExistsResponse, + SkillListResponse, + SkillMutableScope, } from "../types/unified"; // --- Reactive connection signal --- @@ -432,6 +434,33 @@ class GatewayAPI { return gatewayClient.invokeCommand({ sessionId, commandName, args, ...options }); } + // --- Skills --- + + listSkills(workspaceDirectory: string): Promise { + return gatewayClient.listSkills({ workspaceDirectory }); + } + + setSkillEnabled( + workspaceDirectory: string, + name: string, + scope: SkillMutableScope, + enabled: boolean, + ): Promise { + return gatewayClient.setSkillEnabled({ workspaceDirectory, name, scope, enabled }); + } + + deleteSkill( + workspaceDirectory: string, + name: string, + scope: SkillMutableScope, + ): Promise { + return gatewayClient.deleteSkill({ workspaceDirectory, name, scope }); + } + + refreshSkills(workspaceDirectory: string): Promise { + return gatewayClient.refreshSkills({ workspaceDirectory }); + } + // --- File Explorer --- listFiles(directory: string, rootDirectory: string): Promise { diff --git a/src/lib/gateway-client.ts b/src/lib/gateway-client.ts index a3016f5d..b5fbaba9 100644 --- a/src/lib/gateway-client.ts +++ b/src/lib/gateway-client.ts @@ -53,6 +53,11 @@ import { type TerminalCreateResponse, type TerminalListRequest, type TerminalListResponse, + type SkillDeleteRequest, + type SkillListRequest, + type SkillListResponse, + type SkillRefreshRequest, + type SkillSetEnabledRequest, } from "../types/unified"; // --- Event types emitted by GatewayClient --- @@ -555,6 +560,24 @@ export class GatewayClient { return this.request(GatewayRequestType.COMMAND_INVOKE, req, 0); // No timeout, same as sendMessage } + // --- Skill API --- + + listSkills(req: SkillListRequest): Promise { + return this.request(GatewayRequestType.SKILL_LIST, req); + } + + setSkillEnabled(req: SkillSetEnabledRequest): Promise { + return this.request(GatewayRequestType.SKILL_SET_ENABLED, req); + } + + deleteSkill(req: SkillDeleteRequest): Promise { + return this.request(GatewayRequestType.SKILL_DELETE, req); + } + + refreshSkills(req: SkillRefreshRequest): Promise { + return this.request(GatewayRequestType.SKILL_REFRESH, req); + } + // --- Cron / Scheduled Tasks API --- // Note: Cron RPC methods are not yet implemented on the gateway server. // These stubs are provided for future use; calling them will reject diff --git a/src/locales/en.ts b/src/locales/en.ts index f3bf3e87..55ef16d6 100644 --- a/src/locales/en.ts +++ b/src/locales/en.ts @@ -151,6 +151,71 @@ export interface LocaleDict { roleCoder: string; }; + // Skills settings + skill: { + title: string; + description: string; + workspace: string; + workspaceDesc: string; + defaultWorkspace: string; + currentSessionWorkspace: string; + noWorkspace: string; + searchPlaceholder: string; + noSearchResults: string; + refresh: string; + refreshing: string; + empty: string; + diagnostic: string; + openPath: string; + scopeProject: string; + scopeGlobal: string; + scopeBuiltin: string; + scopeProjectDesc: string; + scopeGlobalDesc: string; + scopeBuiltinDesc: string; + effective: string; + enableSkill: string; + disableSkill: string; + enabled: string; + disabled: string; + overridden: string; + disabledAt: string; + effectiveScope: string; + cardDetails: string; + detailsTitle: string; + selectedScope: string; + otherScopes: string; + sourcePath: string; + noSkillsInScope: string; + noOtherScopes: string; + shadowed: string; + notEffective: string; + noDescription: string; + enableForProject: string; + disableForProject: string; + enableGlobally: string; + disableGlobally: string; + deleteSkill: string; + deleteConfirm: string; + shadowedBy: string; + shadows: string; + diagnosticExposureConflict: string; + diagnosticEngineExposureFailed: string; + diagnosticInvalidSkill: string; + diagnosticSkillShadowed: string; + }; + + // Harness configuration surfaces + harness: { + title: string; + skills: string; + agents: string; + mcp: string; + comingSoon: string; + agentsComingSoon: string; + mcpComingSoon: string; + }; + // Remote Access page remote: { title: string; @@ -858,6 +923,71 @@ export const en: LocaleDict = { roleCoder: "Coder", }, + // Skills settings + skill: { + title: "Skills", + description: "Manage CodeMux skills for the selected workspace. Project settings affect only this workspace; global settings affect all workspaces.", + workspace: "Workspace", + workspaceDesc: "Skill state and project-level disable rules are evaluated for this selected workspace.", + defaultWorkspace: "Default", + currentSessionWorkspace: "Current session workspace", + noWorkspace: "No workspace available", + searchPlaceholder: "Search skills...", + noSearchResults: "No skills match your search", + refresh: "Refresh", + refreshing: "Refreshing...", + empty: "No skills found for this workspace", + diagnostic: "Diagnostic", + openPath: "Open path", + scopeProject: "Project", + scopeGlobal: "Global", + scopeBuiltin: "Builtin", + scopeProjectDesc: "Skills stored in this workspace. They override matching global and builtin skills.", + scopeGlobalDesc: "Reusable skills available to every workspace unless a project overrides or disables them.", + scopeBuiltinDesc: "Packaged read-only skills shipped with CodeMux.", + effective: "Effective", + enableSkill: "Enable", + disableSkill: "Disable", + enabled: "Enabled", + disabled: "Disabled", + overridden: "Overridden", + disabledAt: "Disabled at {scopes}", + effectiveScope: "Effective: {scope}", + cardDetails: "View details", + detailsTitle: "Skill details", + selectedScope: "Card scope", + otherScopes: "Other scopes", + sourcePath: "Source path", + noSkillsInScope: "No skills in this scope", + noOtherScopes: "No other scopes for this skill", + shadowed: "Shadowed", + notEffective: "Not effective", + noDescription: "No description", + enableForProject: "Enable for this project", + disableForProject: "Disable for this project", + enableGlobally: "Enable globally", + disableGlobally: "Disable globally", + deleteSkill: "Delete skill", + deleteConfirm: "Delete skill \"{name}\"? This removes only the selected project/global skill files. Builtin skills cannot be deleted.", + shadowedBy: "Shadowed by {scope}", + shadows: "Shadows {scopes}", + diagnosticExposureConflict: "Skill \"{name}\" could not be exposed because {path} already exists and is not managed by CodeMux.", + diagnosticEngineExposureFailed: "Skill \"{name}\" could not be exposed: {reason}.", + diagnosticInvalidSkill: "Skill \"{name}\" is invalid: {reason}.", + diagnosticSkillShadowed: "Skill \"{name}\" is shadowed by {scope}.", + }, + + // Harness configuration surfaces + harness: { + title: "Harnesses", + skills: "Skills", + agents: "Agents", + mcp: "MCP", + comingSoon: "Soon", + agentsComingSoon: "Agents configuration is coming soon", + mcpComingSoon: "MCP configuration is coming soon", + }, + // Remote Access page remote: { title: "Remote Access", diff --git a/src/locales/ru.ts b/src/locales/ru.ts index 1dc4486d..f88fd4ea 100644 --- a/src/locales/ru.ts +++ b/src/locales/ru.ts @@ -150,6 +150,71 @@ export const ru: LocaleDict = { roleCoder: "Программист", }, + // Skills settings + skill: { + title: "Skills", + description: "Управление навыками CodeMux для выбранной рабочей области. Настройки проекта влияют только на эту рабочую область; глобальные настройки влияют на все рабочие области.", + workspace: "Рабочая область", + workspaceDesc: "Состояние навыков и проектные правила отключения вычисляются для выбранной рабочей области.", + defaultWorkspace: "По умолчанию", + currentSessionWorkspace: "Рабочая область текущей сессии", + noWorkspace: "Нет доступной рабочей области", + searchPlaceholder: "Поиск навыков...", + noSearchResults: "Навыки по запросу не найдены", + refresh: "Обновить", + refreshing: "Обновление...", + empty: "В этой рабочей области навыки не найдены", + diagnostic: "Диагностика", + openPath: "Открыть путь", + scopeProject: "Проект", + scopeGlobal: "Глобально", + scopeBuiltin: "Встроенный", + scopeProjectDesc: "Навыки в этой рабочей области. Они перекрывают одноимённые глобальные и встроенные навыки.", + scopeGlobalDesc: "Переиспользуемые навыки для всех рабочих областей, если проект их не перекрывает или не отключает.", + scopeBuiltinDesc: "Встроенные навыки CodeMux только для чтения.", + effective: "Активно", + enableSkill: "Включить", + disableSkill: "Отключить", + enabled: "Включено", + disabled: "Отключено", + overridden: "Перекрыто", + disabledAt: "Отключено в {scopes}", + effectiveScope: "Активно: {scope}", + cardDetails: "Показать детали", + detailsTitle: "Детали навыка", + selectedScope: "Область карточки", + otherScopes: "Другие области", + sourcePath: "Исходный путь", + noSkillsInScope: "В этой области нет навыков", + noOtherScopes: "У этого навыка нет других областей", + shadowed: "Перекрыто", + notEffective: "Не активно", + noDescription: "Нет описания", + enableForProject: "Включить для этого проекта", + disableForProject: "Отключить для этого проекта", + enableGlobally: "Включить глобально", + disableGlobally: "Отключить глобально", + deleteSkill: "Удалить навык", + deleteConfirm: "Удалить навык \"{name}\"? Это удалит только файлы выбранной проектной/глобальной области. Встроенные навыки удалить нельзя.", + shadowedBy: "Перекрыто {scope}", + shadows: "Перекрывает {scopes}", + diagnosticExposureConflict: "Навык \"{name}\" не удалось открыть, потому что путь {path} уже существует и не управляется CodeMux.", + diagnosticEngineExposureFailed: "Навык \"{name}\" не удалось открыть: {reason}.", + diagnosticInvalidSkill: "Навык \"{name}\" недействителен: {reason}.", + diagnosticSkillShadowed: "Навык \"{name}\" перекрыт {scope}.", + }, + + // Harness configuration surfaces + harness: { + title: "Среды", + skills: "Skills", + agents: "Agents", + mcp: "MCP", + comingSoon: "Скоро", + agentsComingSoon: "Настройка Agents скоро появится", + mcpComingSoon: "Настройка MCP скоро появится", + }, + // Remote Access page remote: { title: "Удалённый доступ", diff --git a/src/locales/zh.ts b/src/locales/zh.ts index 589c6c80..81a69197 100644 --- a/src/locales/zh.ts +++ b/src/locales/zh.ts @@ -149,6 +149,71 @@ export const zh: LocaleDict = { roleCoder: "编码者", }, + // Skills settings + skill: { + title: "Skills", + description: "管理所选工作区的 CodeMux skills。项目设置只影响当前工作区;全局设置会影响所有工作区。", + workspace: "工作区", + workspaceDesc: "Skill 状态和项目级禁用规则会按当前选中的工作区计算。", + defaultWorkspace: "默认", + currentSessionWorkspace: "当前会话工作区", + noWorkspace: "没有可用工作区", + searchPlaceholder: "搜索 skills...", + noSearchResults: "没有匹配的 skills", + refresh: "刷新", + refreshing: "刷新中...", + empty: "当前工作区没有找到 skills", + diagnostic: "诊断", + openPath: "打开路径", + scopeProject: "项目", + scopeGlobal: "全局", + scopeBuiltin: "内置", + scopeProjectDesc: "存放在当前工作区的 skills。会覆盖同名全局和内置 skills。", + scopeGlobalDesc: "所有工作区可复用的 skills,除非被项目覆盖或禁用。", + scopeBuiltinDesc: "CodeMux 随包提供的只读内置 skills。", + effective: "生效中", + enableSkill: "启用", + disableSkill: "禁用", + enabled: "已启用", + disabled: "已禁用", + overridden: "被覆盖", + disabledAt: "在 {scopes} 禁用", + effectiveScope: "生效:{scope}", + cardDetails: "查看详情", + detailsTitle: "Skill 详情", + selectedScope: "卡片 scope", + otherScopes: "其他 scope", + sourcePath: "源路径", + noSkillsInScope: "此 scope 下没有 skills", + noOtherScopes: "此 skill 没有其他 scope", + shadowed: "被覆盖", + notEffective: "未生效", + noDescription: "无描述", + enableForProject: "在此项目启用", + disableForProject: "在此项目禁用", + enableGlobally: "全局启用", + disableGlobally: "全局禁用", + deleteSkill: "删除 skill", + deleteConfirm: "删除 skill \"{name}\"?这只会移除当前选中 scope 的项目/全局真实文件。内置 skill 不会被删除。", + shadowedBy: "被 {scope} 覆盖", + shadows: "覆盖 {scopes}", + diagnosticExposureConflict: "Skill \"{name}\" 无法暴露,因为 {path} 已存在且不由 CodeMux 管理。", + diagnosticEngineExposureFailed: "Skill \"{name}\" 无法暴露:{reason}。", + diagnosticInvalidSkill: "Skill \"{name}\" 无效:{reason}。", + diagnosticSkillShadowed: "Skill \"{name}\" 被 {scope} 覆盖。", + }, + + // Harness configuration surfaces + harness: { + title: "扩展能力", + skills: "Skills", + agents: "Agents", + mcp: "MCP", + comingSoon: "即将支持", + agentsComingSoon: "Agents 配置即将支持", + mcpComingSoon: "MCP 配置即将支持", + }, + // Remote Access page remote: { title: "远程访问", diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 831b0459..a248b49e 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -625,6 +625,7 @@ export default function Chat() { const [isMobile, setIsMobile] = createSignal(window.innerWidth < 768); // Desktop sidebar collapse (icon-only mode) const [isSidebarCollapsed, setIsSidebarCollapsed] = createSignal(false); + const [isHarnessNavOpen, setIsHarnessNavOpen] = createSignal(true); const [refreshingSessions, setRefreshingSessions] = createSignal(false); // Send validation error (auto-clears after 3s) @@ -2739,6 +2740,69 @@ export default function Chat() { +
+ + +
+ + + +
+
+
+

{t().skill.title}

+ +
+
+ +
+
+ +
+
+ + ); +} diff --git a/src/types/unified.ts b/src/types/unified.ts index 12148385..4276f85b 100644 --- a/src/types/unified.ts +++ b/src/types/unified.ts @@ -647,6 +647,83 @@ export interface OrchestrationConfirmRequest { subtasks: OrchestrationSubtask[]; } +// --- Skills --- + +export type SkillScope = "builtin" | "global" | "project"; +export type SkillMutableScope = Exclude; + +export interface SkillDisabledAt { + scope: SkillMutableScope; +} + +export interface SkillScopedInstance { + scope: SkillScope; + description?: string; + path?: string; + shadows?: SkillScope[]; + shadowedBy?: SkillScope; +} + +export interface SkillSummary { + name: string; + description?: string; + enabled: boolean; + effectiveScope: SkillScope | null; + disabledAt?: SkillDisabledAt[]; + scopes: SkillScopedInstance[]; +} + +export type SkillDiagnosticSeverity = "info" | "warning" | "error"; + +export type SkillDiagnosticCode = + | "exposure-conflict" + | "invalid-skill" + | "skill-shadowed" + | "engine-exposure-failed"; + +export interface SkillDiagnosticAction { + kind: "open-path" | "refresh" | "delete-conflict"; + path?: string; +} + +export interface SkillDiagnostic { + severity: SkillDiagnosticSeverity; + code: SkillDiagnosticCode; + params?: Record; + skillName?: string; + engineType?: EngineType; + action?: SkillDiagnosticAction; +} + +export interface SkillListRequest { + workspaceDirectory: string; +} + +export interface SkillSetEnabledRequest { + workspaceDirectory: string; + name: string; + scope: SkillMutableScope; + enabled: boolean; +} + +export interface SkillDeleteRequest { + workspaceDirectory: string; + name: string; + scope: SkillMutableScope; +} + +export interface SkillRefreshRequest { + workspaceDirectory: string; + engineTypes?: EngineType[]; +} + +export interface SkillListResponse { + workspaceDirectory: string; + effectiveRoot: string; + skills: SkillSummary[]; + diagnostics: SkillDiagnostic[]; +} + // ============================================================================ // WebSocket Gateway Protocol Types // ============================================================================ @@ -757,6 +834,12 @@ export const GatewayRequestType = { COMMAND_LIST: "command.list", COMMAND_INVOKE: "command.invoke", + // Skills + SKILL_LIST: "skill.list", + SKILL_SET_ENABLED: "skill.setEnabled", + SKILL_DELETE: "skill.delete", + SKILL_REFRESH: "skill.refresh", + // Cron / Scheduled Tasks CRON_CREATE: "cron.create", CRON_DELETE: "cron.delete", diff --git a/tests/e2e/specs/theme-lang.spec.ts b/tests/e2e/specs/theme-lang.spec.ts index 3037879f..74ff575f 100644 --- a/tests/e2e/specs/theme-lang.spec.ts +++ b/tests/e2e/specs/theme-lang.spec.ts @@ -34,15 +34,15 @@ test.describe("Theme & Language", () => { }); test("should switch language", async ({ page }) => { - // Look for language switcher (LanguageSwitcher component) - const langSwitcher = page - .getByRole("button", { name: /language|lang|中文|english|EN|ZH/i }) - .or(page.locator("[class*='language'], [class*='lang']").first()); - - if (await langSwitcher.isVisible({ timeout: 5_000 }).catch(() => false)) { - await langSwitcher.click(); - await page.waitForTimeout(300); - // After switching, UI text should change (verified by not crashing) - } + await page.getByRole("button", { name: /Settings|设置|Настройки/i }).click(); + await page.waitForTimeout(500); + + const langSwitcher = page.getByRole("button", { name: /English|简体中文|Русский/i }).first(); + await expect(langSwitcher).toBeVisible({ timeout: 5_000 }); + + await langSwitcher.click(); + await page.getByRole("button", { name: /简体中文/i }).click(); + + await expect(page.getByRole("button", { name: /简体中文/i }).first()).toBeVisible({ timeout: 5_000 }); }); }); diff --git a/tests/unit/electron/engines/claude/index.test.ts b/tests/unit/electron/engines/claude/index.test.ts index ba77e179..adf203f2 100644 --- a/tests/unit/electron/engines/claude/index.test.ts +++ b/tests/unit/electron/engines/claude/index.test.ts @@ -1602,6 +1602,28 @@ describe("ClaudeCodeAdapter", () => { }); }); + describe("refreshSkillsForDirectory()", () => { + it("reloads plugins for matching active sessions and updates commands", async () => { + const mock = makeMockV2Session(); + mock.reloadPlugins = vi.fn().mockResolvedValue({ + commands: [{ name: "hot-skill", description: "Hot reloaded", argumentHint: "" }], + error_count: 0, + }); + const scanSpy = vi.spyOn(ClaudeCodeAdapter as any, "scanSkillsDir").mockReturnValue([]); + const warmupSpy = vi.spyOn(adapter as any, "warmupV2Session"); + seedV2Session(adapter, "cs_1", mock, "/repo"); + + await adapter.refreshSkillsForDirectory("/repo"); + + expect(mock.reloadPlugins).toHaveBeenCalledTimes(1); + expect(warmupSpy).not.toHaveBeenCalled(); + expect((adapter as any).availableCommands).toEqual([ + { name: "hot-skill", description: "Hot reloaded", argumentHint: "" }, + ]); + scanSpy.mockRestore(); + }); + }); + describe("isBuiltInCommand()", () => { it("returns true for built-in commands", () => { expect((adapter as any).isBuiltInCommand("compact")).toBe(true); diff --git a/tests/unit/electron/engines/codex/index.test.ts b/tests/unit/electron/engines/codex/index.test.ts index 65505bb3..989feacb 100644 --- a/tests/unit/electron/engines/codex/index.test.ts +++ b/tests/unit/electron/engines/codex/index.test.ts @@ -854,6 +854,10 @@ describe("CodexAdapter", () => { (adapter as any).handleSkillsChanged(); await flushMicrotasks(); + const skillListCalls = client.request.mock.calls.filter((call) => call[0] === "skills/list"); + expect(skillListCalls[0][1]).toEqual({ cwds: ["/repo"], forceReload: false }); + expect(skillListCalls[1][1]).toEqual({ cwds: ["/repo"], forceReload: true }); + expect(commandEvents).toEqual([ ["fix:true", "plan:false"], ["fix:true"], diff --git a/tests/unit/electron/engines/copilot/index.test.ts b/tests/unit/electron/engines/copilot/index.test.ts index 88acef26..eba65090 100644 --- a/tests/unit/electron/engines/copilot/index.test.ts +++ b/tests/unit/electron/engines/copilot/index.test.ts @@ -43,6 +43,7 @@ const { skills: { ensureLoaded: vi.fn(async function() {}), list: vi.fn(async function() { return { skills: [] }; }), + reload: vi.fn(async function() {}), enable: vi.fn(async function() {}), disable: vi.fn(async function() {}), }, @@ -190,6 +191,7 @@ function makeMockSession(sessionId = "s1") { skills: { ensureLoaded: vi.fn(async () => {}), list: vi.fn(async () => ({ skills: [] })), + reload: vi.fn(async () => {}), enable: vi.fn(async () => {}), disable: vi.fn(async () => {}), }, @@ -2176,6 +2178,38 @@ describe("CopilotSdkAdapter", () => { }); }); + describe("refreshSkillsForDirectory()", () => { + it("reloads skills in matching active sessions and refreshes the command cache", async () => { + const skillProjection = { + prepareForEngine: vi.fn(async () => ({ + skillDirectories: ["/effective-root"], + skillNames: ["hot-skill"], + conflicts: [], + })), + }; + adapter = new CopilotSdkAdapter({ + cliPath: "/usr/local/bin/copilot", + skillProjection: skillProjection as any, + }); + const sess = makeMockSession("s1"); + sess.rpc.skills.list.mockResolvedValueOnce({ + skills: [{ name: "hot-skill", description: "Hot reloaded", source: "project" }], + }); + (adapter as any).activeSessions.set("s1", sess); + (adapter as any).sessionDirectories.set("s1", "/repo"); + (adapter as any).cachedCommands = [{ name: "stale", description: "Stale" }]; + + await adapter.refreshSkillsForDirectory("/repo"); + + expect(skillProjection.prepareForEngine).toHaveBeenCalledWith("copilot", "/repo"); + expect(sess.rpc.skills.reload).toHaveBeenCalledTimes(1); + expect(sess.disconnect).not.toHaveBeenCalled(); + expect((adapter as any).cachedCommands).toEqual([ + { name: "hot-skill", description: "Hot reloaded" }, + ]); + }); + }); + describe("listCommands()", () => { it("returns cached commands immediately when cache is populated", async () => { (adapter as any).cachedCommands = [{ name: "fix", description: "Fix" }]; diff --git a/tests/unit/electron/engines/opencode/index.test.ts b/tests/unit/electron/engines/opencode/index.test.ts index be13d003..74ca02a1 100644 --- a/tests/unit/electron/engines/opencode/index.test.ts +++ b/tests/unit/electron/engines/opencode/index.test.ts @@ -80,6 +80,9 @@ function createMockClient() { command: { list: vi.fn().mockResolvedValue({ data: [], error: null }), }, + app: { + skills: vi.fn().mockResolvedValue({ data: [], error: null }), + }, global: { event: vi.fn().mockResolvedValue({ stream: (async function* () {})() }), health: vi.fn().mockResolvedValue({ data: true }), @@ -1823,6 +1826,29 @@ describe("OpenCodeAdapter", () => { expect(client.command.list).toHaveBeenCalledWith({ directory: "/my-project" }); }); + it("refreshSkillsForDirectory merges OpenCode skills into the command cache", async () => { + const { adapter } = createAdapterWithClient(); + const scopedClient = createMockClient(); + scopedClient.command.list.mockResolvedValue({ + data: [{ name: "help", description: "Help", template: "topic" }], + error: null, + }); + scopedClient.app.skills.mockResolvedValue({ + data: [{ name: "hot-skill", description: "Hot reloaded", location: "project" }], + error: null, + }); + mockCreateOpencodeClient.mockReturnValue(scopedClient); + + await adapter.refreshSkillsForDirectory("/repo"); + + expect(scopedClient.command.list).toHaveBeenCalledWith({ directory: "/repo" }); + expect(scopedClient.app.skills).toHaveBeenCalledWith({ directory: "/repo" }); + expect((adapter as any).cachedCommands).toEqual([ + { name: "help", description: "Help", argumentHint: "" }, + { name: "hot-skill", description: "Hot reloaded", source: "project" }, + ]); + }); + it("listCommands returns cached commands without making any API call", async () => { const { adapter, client } = createAdapterWithClient(); (adapter as any).cachedCommands = [ diff --git a/tests/unit/electron/gateway/engine-manager.test.ts b/tests/unit/electron/gateway/engine-manager.test.ts index bf89d20f..fccd8f87 100644 --- a/tests/unit/electron/gateway/engine-manager.test.ts +++ b/tests/unit/electron/gateway/engine-manager.test.ts @@ -107,6 +107,7 @@ class MockEngineAdapter extends EngineAdapter { listProjects = vi.fn(async () => []); listHistoricalSessions = vi.fn(async () => []); getHistoricalMessages = vi.fn(async () => []); + refreshSkillsForDirectory = vi.fn(async () => {}); listCommands = vi.fn(async () => []); invokeCommand = vi.fn(async () => ({ handledAsCommand: true }) as any); } @@ -240,6 +241,51 @@ describe("EngineManager", () => { }); }); + // =========================================================================== + // Skills + // =========================================================================== + + describe("Skills", () => { + beforeEach(() => { + engineManager.registerAdapter(adapterA); + engineManager.registerAdapter(adapterB); + }); + + it("serializes skill refreshes for the same directory", async () => { + const events: string[] = []; + let resolveStarted!: () => void; + let resolveRefresh!: () => void; + const started = new Promise((resolve) => { + resolveStarted = resolve; + }); + const refreshCanFinish = new Promise((resolve) => { + resolveRefresh = resolve; + }); + + adapterA.refreshSkillsForDirectory.mockImplementationOnce(async () => { + events.push("a:start"); + resolveStarted(); + await refreshCanFinish; + events.push("a:end"); + }); + adapterB.refreshSkillsForDirectory.mockImplementationOnce(async () => { + events.push("b:start"); + }); + + const firstRefresh = engineManager.refreshSkillsForDirectory("/repo", [adapterA.engineType]); + await started; + const secondRefresh = engineManager.refreshSkillsForDirectory("/repo", [adapterB.engineType]); + await Promise.resolve(); + + expect(events).toEqual(["a:start"]); + + resolveRefresh(); + await Promise.all([firstRefresh, secondRefresh]); + + expect(events).toEqual(["a:start", "a:end", "b:start"]); + }); + }); + // =========================================================================== // Engine Info // =========================================================================== diff --git a/tests/unit/electron/gateway/ws-server.test.ts b/tests/unit/electron/gateway/ws-server.test.ts index e2a562d7..bda5c97e 100644 --- a/tests/unit/electron/gateway/ws-server.test.ts +++ b/tests/unit/electron/gateway/ws-server.test.ts @@ -171,6 +171,7 @@ function createMockEngineManager() { importExecute: vi.fn(async () => ({ imported: 0 })), listCommands: vi.fn(async () => []), invokeCommand: vi.fn(async () => ({})), + refreshSkillsForDirectory: vi.fn(async () => {}), }; } @@ -209,6 +210,7 @@ interface TestHarness { */ function createTestHarness(options?: { authValidator?: (token: string) => boolean; + skillApi?: any; }): TestHarness { const engineManager = createMockEngineManager(); const server = new GatewayServer(engineManager as any, options); @@ -751,6 +753,79 @@ describe("GatewayServer", () => { expect(response.payload).toEqual({ imageAttachment: true }); }); + it("SKILL_LIST delegates to the skill API", async () => { + const skillApi = { + listSkills: vi.fn(async () => ({ skills: [], diagnostics: [], effectiveRoot: "/effective", workspaceDirectory: "/repo" })), + }; + const { connect, sendMessage } = createTestHarness({ skillApi }); + const ws = connect(); + + await sendMessage(ws, { + type: GatewayRequestType.SKILL_LIST, + requestId: "r1", + payload: { workspaceDirectory: "/repo" }, + }); + + expect(skillApi.listSkills).toHaveBeenCalledWith({ workspaceDirectory: "/repo" }); + const response = JSON.parse(ws.send.mock.calls[0][0]); + expect(response.payload).toEqual({ + skills: [], + diagnostics: [], + effectiveRoot: "/effective", + workspaceDirectory: "/repo", + }); + }); + + it("SKILL_SET_ENABLED delegates with registered engine types", async () => { + const skillApi = { + setSkillEnabled: vi.fn(async () => ({ skills: [], diagnostics: [], effectiveRoot: "/effective", workspaceDirectory: "/repo" })), + }; + const { connect, sendMessage, engineManager } = createTestHarness({ skillApi }); + const ws = connect(); + + await sendMessage(ws, { + type: GatewayRequestType.SKILL_SET_ENABLED, + requestId: "r1", + payload: { + workspaceDirectory: "/repo", + name: "alpha", + scope: "project", + enabled: false, + }, + }); + + expect(engineManager.listEngines).toHaveBeenCalled(); + expect(skillApi.setSkillEnabled).toHaveBeenCalledWith( + { + workspaceDirectory: "/repo", + name: "alpha", + scope: "project", + enabled: false, + }, + ["claude"], + ); + expect(engineManager.refreshSkillsForDirectory).toHaveBeenCalledWith("/repo", ["claude"]); + }); + + it("SKILL_REFRESH refreshes only requested engines", async () => { + const skillApi = { + refreshSkills: vi.fn(async () => ({ skills: [], diagnostics: [], effectiveRoot: "/effective", workspaceDirectory: "/repo" })), + }; + const { connect, sendMessage, engineManager } = createTestHarness({ skillApi }); + engineManager.listEngines.mockReturnValue([{ type: "claude" }, { type: "opencode" }]); + const ws = connect(); + + const payload = { workspaceDirectory: "/repo", engineTypes: ["opencode"] }; + await sendMessage(ws, { + type: GatewayRequestType.SKILL_REFRESH, + requestId: "r1", + payload, + }); + + expect(skillApi.refreshSkills).toHaveBeenCalledWith(payload, ["claude", "opencode"]); + expect(engineManager.refreshSkillsForDirectory).toHaveBeenCalledWith("/repo", ["opencode"]); + }); + it("SESSION_CREATE delegates with correct params", async () => { const { connect, sendMessage, engineManager } = createTestHarness(); const ws = connect(); diff --git a/tests/unit/electron/services/skill-services.test.ts b/tests/unit/electron/services/skill-services.test.ts new file mode 100644 index 00000000..2cb0463d --- /dev/null +++ b/tests/unit/electron/services/skill-services.test.ts @@ -0,0 +1,398 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +vi.mock("electron", () => ({ + app: { + isPackaged: false, + getPath: vi.fn((name: string) => path.join(os.tmpdir(), "codemux-test", name)), + }, +})); + +vi.mock("../../../../electron/main/services/logger", () => ({ + loadSettings: vi.fn(() => ({})), + saveSettings: vi.fn(), + skillLog: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }, +})); + +import { SkillRegistryService } from "../../../../electron/main/services/skill-registry-service"; +import { SkillProjectionService } from "../../../../electron/main/services/skill-projection-service"; +import { SkillApiService } from "../../../../electron/main/services/skill-api-service"; + +async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +async function createSkill(root: string, name: string, content = "body"): Promise { + const skillPath = path.join(root, name); + await fs.mkdir(skillPath, { recursive: true }); + await fs.writeFile(path.join(skillPath, "SKILL.md"), `---\ndescription: ${name}\n---\n${content}\n`, "utf8"); + return skillPath; +} + +function normalize(filePath: string): string { + const resolved = path.resolve(filePath); + return process.platform === "win32" ? resolved.toLowerCase() : resolved; +} + +async function readResolvedLink(linkPath: string): Promise { + const raw = await fs.readlink(linkPath); + return path.isAbsolute(raw) ? raw : path.resolve(path.dirname(linkPath), raw); +} + +describe("skill services", () => { + let tempRoot: string; + let builtinRoot: string; + let globalRoot: string; + let effectiveRoot: string; + let manifestsRoot: string; + let workspace: string; + let settings: Record; + + beforeEach(async () => { + tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "codemux-skills-")); + builtinRoot = path.join(tempRoot, "builtin"); + globalRoot = path.join(tempRoot, "global"); + effectiveRoot = path.join(tempRoot, "effective"); + manifestsRoot = path.join(tempRoot, "manifests"); + workspace = path.join(tempRoot, "workspace"); + settings = {}; + await fs.mkdir(workspace, { recursive: true }); + }); + + afterEach(async () => { + await fs.rm(tempRoot, { recursive: true, force: true }); + }); + + function createRegistry(): SkillRegistryService { + return new SkillRegistryService({ + builtinSkillsRoot: builtinRoot, + globalSkillsRoot: globalRoot, + effectiveRootsRoot: effectiveRoot, + loadSettings: () => settings, + saveSettings: (patch) => { + settings = { + ...settings, + ...patch, + skills: { + ...(typeof settings.skills === "object" && settings.skills ? settings.skills : {}), + ...(typeof patch.skills === "object" && patch.skills ? patch.skills : {}), + }, + }; + }, + logger: { + error: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + verbose: vi.fn(), + debug: vi.fn(), + silly: vi.fn(), + }, + }); + } + + describe("SkillRegistryService.buildEffectiveSkillSet", () => { + it("uses project skills before global and builtin skills with the same name", async () => { + await createSkill(builtinRoot, "alpha", "builtin"); + await createSkill(globalRoot, "alpha", "global"); + const projectSkill = await createSkill(path.join(workspace, ".codemux", "skills"), "alpha", "project"); + + const effective = await createRegistry().buildEffectiveSkillSet(workspace); + + expect(effective.skills.map((skill) => `${skill.name}:${skill.scope}`)).toEqual(["alpha:project"]); + expect(normalize(await readResolvedLink(path.join(effective.effectiveRoot, "alpha")))).toBe(normalize(projectSkill)); + }); + + it("filters disabled skills without deleting the real skill directory", async () => { + const realSkill = await createSkill(globalRoot, "alpha"); + settings = { skills: { disabled: ["alpha"] } }; + + const effective = await createRegistry().buildEffectiveSkillSet(workspace); + + expect(effective.skills).toEqual([]); + expect(await pathExists(realSkill)).toBe(true); + expect(await pathExists(path.join(effective.effectiveRoot, "alpha"))).toBe(false); + }); + + it("falls back to global skills when project skills are disabled", async () => { + const globalSkill = await createSkill(globalRoot, "alpha", "global"); + await createSkill(path.join(workspace, ".codemux", "skills"), "alpha", "project"); + await fs.mkdir(path.join(workspace, ".codemux"), { recursive: true }); + await fs.writeFile( + path.join(workspace, ".codemux", "skills.json"), + JSON.stringify({ disabled: ["alpha"] }), + "utf8", + ); + + const effective = await createRegistry().buildEffectiveSkillSet(workspace); + + expect(effective.skills.map((skill) => `${skill.name}:${skill.scope}`)).toEqual(["alpha:global"]); + expect(normalize(await readResolvedLink(path.join(effective.effectiveRoot, "alpha")))).toBe(normalize(globalSkill)); + }); + + it("falls back to builtin skills when global skills are disabled", async () => { + const builtinSkill = await createSkill(builtinRoot, "alpha", "builtin"); + await createSkill(globalRoot, "alpha", "global"); + settings = { skills: { disabled: ["alpha"] } }; + + const effective = await createRegistry().buildEffectiveSkillSet(workspace); + + expect(effective.skills.map((skill) => `${skill.name}:${skill.scope}`)).toEqual(["alpha:builtin"]); + expect(normalize(await readResolvedLink(path.join(effective.effectiveRoot, "alpha")))).toBe(normalize(builtinSkill)); + }); + + it("deletes only the selected real skill scope", async () => { + const registry = createRegistry(); + const globalSkill = await createSkill(globalRoot, "alpha"); + const projectSkill = await createSkill(path.join(workspace, ".codemux", "skills"), "alpha"); + + await registry.deleteSkill("project", "alpha", workspace); + + expect(await pathExists(projectSkill)).toBe(false); + expect(await pathExists(globalSkill)).toBe(true); + const effective = await registry.buildEffectiveSkillSet(workspace); + expect(effective.skills.map((skill) => `${skill.name}:${skill.scope}`)).toEqual(["alpha:global"]); + }); + }); + + describe("SkillRegistryService.listSkillSummaries", () => { + it("returns logical skill state without projection details", async () => { + const builtinSkill = await createSkill(builtinRoot, "alpha", "builtin"); + const globalSkill = await createSkill(globalRoot, "alpha", "global"); + const projectSkill = await createSkill(path.join(workspace, ".codemux", "skills"), "alpha", "project"); + + const snapshot = await createRegistry().listSkillSummaries(workspace); + + expect(snapshot.skills).toEqual([ + { + name: "alpha", + description: "alpha", + enabled: true, + effectiveScope: "project", + scopes: [ + { scope: "project", description: "alpha", path: projectSkill, shadows: ["global", "builtin"] }, + { scope: "global", description: "alpha", path: globalSkill, shadowedBy: "project" }, + { scope: "builtin", description: "alpha", path: builtinSkill, shadowedBy: "project" }, + ], + }, + ]); + }); + + it("updates global disabled state without deleting real skill files", async () => { + const registry = createRegistry(); + const realSkill = await createSkill(globalRoot, "alpha"); + + await registry.setSkillEnabled("global", "alpha", false, workspace); + const disabled = await registry.listSkillSummaries(workspace); + + expect(disabled.skills[0]).toMatchObject({ + name: "alpha", + enabled: false, + effectiveScope: null, + disabledAt: [{ scope: "global" }], + }); + expect(await pathExists(realSkill)).toBe(true); + + await registry.setSkillEnabled("global", "alpha", true, workspace); + const enabled = await registry.listSkillSummaries(workspace); + + expect(enabled.skills[0]).toMatchObject({ + name: "alpha", + enabled: true, + effectiveScope: "global", + }); + }); + + it("reports global skills as effective when a shadowing project skill is disabled", async () => { + const builtinSkill = await createSkill(builtinRoot, "alpha", "builtin"); + const globalSkill = await createSkill(globalRoot, "alpha", "global"); + const projectSkill = await createSkill(path.join(workspace, ".codemux", "skills"), "alpha", "project"); + await fs.mkdir(path.join(workspace, ".codemux"), { recursive: true }); + await fs.writeFile( + path.join(workspace, ".codemux", "skills.json"), + JSON.stringify({ disabled: ["alpha"] }), + "utf8", + ); + + const snapshot = await createRegistry().listSkillSummaries(workspace); + + expect(snapshot.skills).toEqual([ + { + name: "alpha", + description: "alpha", + enabled: true, + effectiveScope: "global", + disabledAt: [{ scope: "project" }], + scopes: [ + { scope: "project", description: "alpha", path: projectSkill }, + { scope: "global", description: "alpha", path: globalSkill, shadows: ["builtin"] }, + { scope: "builtin", description: "alpha", path: builtinSkill, shadowedBy: "global" }, + ], + }, + ]); + }); + }); + + describe("SkillProjectionService.prepareForEngine", () => { + it("returns the effective root for Copilot custom skill directories", async () => { + await createSkill(globalRoot, "alpha"); + const projection = new SkillProjectionService({ + registry: createRegistry(), + manifestsRoot, + }); + + const result = await projection.prepareForEngine("copilot", workspace); + + expect(result.strategy).toBe("pass-root-directory"); + expect(result.effectiveRoot).toBeTruthy(); + expect(result.projectedRoot).toBeNull(); + expect(result.skillNames).toEqual(["alpha"]); + expect(result.skillDirectories).toEqual([result.effectiveRoot]); + expect(await pathExists(path.join(result.effectiveRoot!, "alpha"))).toBe(true); + }); + + it("returns the effective root for Codex standalone skill roots", async () => { + await createSkill(globalRoot, "alpha"); + const projection = new SkillProjectionService({ + registry: createRegistry(), + manifestsRoot, + }); + + const result = await projection.prepareForEngine("codex", workspace); + + expect(result.strategy).toBe("pass-root-directory"); + expect(result.effectiveRoot).toBeTruthy(); + expect(result.projectedRoot).toBeNull(); + expect(result.skillNames).toEqual(["alpha"]); + expect(result.skillDirectories).toEqual([result.effectiveRoot]); + expect(await pathExists(path.join(result.effectiveRoot!, "alpha"))).toBe(true); + }); + + it("returns a stable effective root even before Copilot skills exist", async () => { + const projection = new SkillProjectionService({ + registry: createRegistry(), + manifestsRoot, + }); + + const result = await projection.prepareForEngine("copilot", workspace); + + expect(result.skillNames).toEqual([]); + expect(result.effectiveRoot).toBeTruthy(); + expect(result.skillDirectories).toEqual([result.effectiveRoot]); + expect(await pathExists(result.effectiveRoot!)).toBe(true); + }); + + it("does not overwrite unmanaged discovery-directory conflicts", async () => { + await createSkill(globalRoot, "alpha"); + const conflictPath = path.join(workspace, ".opencode", "skills", "alpha"); + await fs.mkdir(conflictPath, { recursive: true }); + const projection = new SkillProjectionService({ + registry: createRegistry(), + manifestsRoot, + }); + + const result = await projection.prepareForEngine("opencode", workspace); + + expect(result.strategy).toBe("link-into-discovery-dir"); + expect(result.conflicts).toEqual([ + expect.objectContaining({ name: "alpha", path: conflictPath, reason: "discovery-path-conflict" }), + ]); + expect((await fs.lstat(conflictPath)).isSymbolicLink()).toBe(false); + }); + + it("removes only manifest-managed links when a skill is disabled", async () => { + const realSkill = await createSkill(globalRoot, "alpha"); + const registry = createRegistry(); + const projection = new SkillProjectionService({ + registry, + manifestsRoot, + }); + + const enabled = await projection.prepareForEngine("claude", workspace); + const projectedPath = path.join(enabled.projectedRoot!, "alpha"); + expect((await fs.lstat(projectedPath)).isSymbolicLink()).toBe(true); + + settings = { skills: { disabled: ["alpha"] } }; + const disabled = await projection.prepareForEngine("claude", workspace); + + expect(disabled.skillNames).toEqual([]); + expect(await pathExists(projectedPath)).toBe(false); + expect(await pathExists(realSkill)).toBe(true); + }); + + it("maintains a Git exclude block for managed discovery links", async () => { + await createSkill(globalRoot, "alpha"); + await fs.mkdir(path.join(workspace, ".git", "info"), { recursive: true }); + const registry = createRegistry(); + const projection = new SkillProjectionService({ + registry, + manifestsRoot, + }); + + await projection.prepareForEngine("opencode", workspace); + const excludePath = path.join(workspace, ".git", "info", "exclude"); + const enabledExclude = await fs.readFile(excludePath, "utf8"); + expect(enabledExclude).toContain("CodeMux managed skill projections begin opencode-"); + expect(enabledExclude).toContain("/.opencode/skills/alpha"); + + settings = { skills: { disabled: ["alpha"] } }; + await projection.prepareForEngine("opencode", workspace); + + const disabledExclude = await fs.readFile(excludePath, "utf8"); + expect(disabledExclude).not.toContain("/.opencode/skills/alpha"); + }); + }); + + describe("SkillApiService.refreshSkills", () => { + it("returns projection problems as separate diagnostics", async () => { + await createSkill(globalRoot, "alpha"); + const conflictPath = path.join(workspace, ".opencode", "skills", "alpha"); + await fs.mkdir(conflictPath, { recursive: true }); + const registry = createRegistry(); + const projection = new SkillProjectionService({ + registry, + manifestsRoot, + }); + const api = new SkillApiService({ registry, projection }); + + const response = await api.refreshSkills({ workspaceDirectory: workspace }, ["opencode"]); + + expect(response.skills).toEqual([ + expect.objectContaining({ + name: "alpha", + enabled: true, + effectiveScope: "global", + }), + ]); + expect(response.diagnostics).toEqual([ + expect.objectContaining({ + severity: "warning", + code: "exposure-conflict", + skillName: "alpha", + engineType: "opencode", + params: { + name: "alpha", + path: conflictPath, + }, + action: expect.objectContaining({ + kind: "open-path", + path: conflictPath, + }), + }), + ]); + }); + }); +}); diff --git a/tests/unit/src/lib/gateway-api.test.ts b/tests/unit/src/lib/gateway-api.test.ts index 38003079..704844a5 100644 --- a/tests/unit/src/lib/gateway-api.test.ts +++ b/tests/unit/src/lib/gateway-api.test.ts @@ -30,6 +30,10 @@ const mockGatewayClient = { importExecute: vi.fn().mockResolvedValue({ imported: 0 }), listCommands: vi.fn().mockResolvedValue([]), invokeCommand: vi.fn().mockResolvedValue({ result: 'ok' }), + listSkills: vi.fn().mockResolvedValue({ skills: [], diagnostics: [], workspaceDirectory: '/repo', effectiveRoot: '/effective' }), + setSkillEnabled: vi.fn().mockResolvedValue({ skills: [], diagnostics: [], workspaceDirectory: '/repo', effectiveRoot: '/effective' }), + deleteSkill: vi.fn().mockResolvedValue({ skills: [], diagnostics: [], workspaceDirectory: '/repo', effectiveRoot: '/effective' }), + refreshSkills: vi.fn().mockResolvedValue({ skills: [], diagnostics: [], workspaceDirectory: '/repo', effectiveRoot: '/effective' }), listFiles: vi.fn().mockResolvedValue([]), readFile: vi.fn().mockResolvedValue({ content: '' }), getGitStatus: vi.fn().mockResolvedValue([]), @@ -307,6 +311,37 @@ describe('GatewayAPI', () => { expect(mockGatewayClient.listCommands).toHaveBeenCalledWith({ engineType: 'claude', sessionId: 's1' }); }); + // --- Skills --- + + it('listSkills delegates', async () => { + await gateway.listSkills('/repo'); + expect(mockGatewayClient.listSkills).toHaveBeenCalledWith({ workspaceDirectory: '/repo' }); + }); + + it('setSkillEnabled delegates', async () => { + await gateway.setSkillEnabled('/repo', 'alpha', 'project', false); + expect(mockGatewayClient.setSkillEnabled).toHaveBeenCalledWith({ + workspaceDirectory: '/repo', + name: 'alpha', + scope: 'project', + enabled: false, + }); + }); + + it('deleteSkill delegates', async () => { + await gateway.deleteSkill('/repo', 'alpha', 'global'); + expect(mockGatewayClient.deleteSkill).toHaveBeenCalledWith({ + workspaceDirectory: '/repo', + name: 'alpha', + scope: 'global', + }); + }); + + it('refreshSkills delegates', async () => { + await gateway.refreshSkills('/repo'); + expect(mockGatewayClient.refreshSkills).toHaveBeenCalledWith({ workspaceDirectory: '/repo' }); + }); + // --- File explorer --- it('listFiles delegates', async () => {