From 0f4796b1e9f758c66de7e37e7a17b09d5a734959 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 14 Sep 2026 04:45:56 +0800 Subject: [PATCH 1/7] fix(desktop): open supported Skill directories Generated-by: Codex --- .../__tests__/command-palette-retired.test.ts | 19 ++ .../__tests__/module-hub-provider.test.ts | 1 + .../module-hub-services-adapter.test.ts | 5 + .../module-hub-skills-controller.test.ts | 56 ++++-- .../runtime-host-skills-ipc-main.test.ts | 32 ++++ .../main/__tests__/skill-locations.test.ts | 136 ++++++++++++++ .../src/main/runtime-host-skills-ipc-main.ts | 37 ++++ apps/desktop/src/main/skill-locations.ts | 173 ++++++++++++++++++ apps/desktop/src/preload/bridge-contract.d.ts | 9 +- apps/desktop/src/preload/preload.ts | 8 + .../src/renderer/app-shell-command-actions.ts | 2 - .../src/renderer/app-shell-project-actions.ts | 24 --- apps/desktop/src/renderer/app-shell.tsx | 4 +- .../src/renderer/command-palette-commands.ts | 11 -- .../renderer/features/module-hub/README.md | 4 +- .../controller/use-module-hub-controller.ts | 4 +- .../controller/use-skills-controller.ts | 139 +++++++++++++- .../src/renderer/features/module-hub/ports.ts | 20 ++ .../renderer/features/module-hub/testing.ts | 3 + .../src/renderer/locales/shell-copy.ts | 53 ++++-- .../desktop/create-module-hub-services.ts | 2 + apps/desktop/stories/module-hubs.stories.tsx | 10 +- .../__tests__/skills-panel-locations.test.tsx | 141 ++++++++++++++ packages/ui/src/components.tsx | 2 +- packages/ui/src/module-pages.tsx | 5 +- packages/ui/src/module-panel-types.ts | 16 ++ packages/ui/src/skills-copy.ts | 18 +- packages/ui/src/skills-panel.tsx | 47 ++++- 28 files changed, 886 insertions(+), 95 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/skill-locations.test.ts create mode 100644 apps/desktop/src/main/skill-locations.ts create mode 100644 packages/ui/src/__tests__/skills-panel-locations.test.tsx diff --git a/apps/desktop/src/main/__tests__/command-palette-retired.test.ts b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts index 46b8fff553..e77c9cd51c 100644 --- a/apps/desktop/src/main/__tests__/command-palette-retired.test.ts +++ b/apps/desktop/src/main/__tests__/command-palette-retired.test.ts @@ -85,6 +85,25 @@ test('a stale in-memory default pointing at a retired connection is not testable assert.ok(!ids.includes('diag:test-default')); }); +test('the palette opens the Skills surface instead of an ambiguous Skills folder', () => { + const ids = buildCommandList({ + locale: 'en', + activeSessionId: undefined, + themePref: 'auto', + connections: [], + defaultSlug: null, + onNewChat: () => {}, + onOpenSettings: () => {}, + onOpenSettingsSection: () => {}, + onOpenShortcuts: () => {}, + onSetTheme: () => {}, + onSelectModule: () => {}, + }).map((command) => command.id); + + assert.ok(ids.includes('nav:skills')); + assert.ok(!ids.includes('diag:open-skills')); +}); + for (const locale of ['en', 'zh-CN'] as const) { test(`${locale} static shortcut hints preserve both platform variants`, () => { const commands = buildCommandList({ diff --git a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts index a743e86c9f..a56e5aa7fa 100644 --- a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts @@ -111,6 +111,7 @@ test('controller scoping removes shell-wide work from Module Hub updates', async const controllerInput = { selection: { section: 'sessions' } as const, selectModule: () => undefined, + clientPathsAccessible: false, useSkillInChat: () => undefined, openSession: () => undefined, appendComposerText: () => undefined, diff --git a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts index 3a7b8712d2..585e8e07b2 100644 --- a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts @@ -58,6 +58,7 @@ describe('createDesktopModuleHubServices', () => { skills: Object.assign(methodRecorder(calls, 'skills'), { sources: methodRecorder(calls, 'skills.sources'), catalog: methodRecorder(calls, 'skills.catalog'), + locations: methodRecorder(calls, 'skills.locations'), }), scheduledTasks: methodRecorder(calls, 'scheduledTasks'), dailyReview: methodRecorder(calls, 'dailyReview'), @@ -71,6 +72,7 @@ describe('createDesktopModuleHubServices', () => { assert.deepEqual(await services.runtimeHosts.getDefault(), host); await services.skills.list(host); + await services.skills.listLocations(host); await services.skills.listManagedSources(host); await services.skills.listBundledCatalog(host); await services.skills.importManagedSource(host); @@ -82,6 +84,7 @@ describe('createDesktopModuleHubServices', () => { await services.skills.setPinned('user:skill', false, host); await services.skills.delete('user:skill', host); await services.skills.open('skill', 'directory', host); + await services.skills.openLocation('user:agents', { createIfMissing: true }, host); const createInput = { title: 'Task' } as Parameters< typeof services.scheduledTasks.create @@ -110,6 +113,7 @@ describe('createDesktopModuleHubServices', () => { assert.deepEqual(calls, [ { name: 'skills.list', args: [host] }, + { name: 'skills.locations.list', args: [host] }, { name: 'skills.sources.list', args: [host] }, { name: 'skills.catalog.list', args: [host] }, { name: 'skills.sources.importLocalFile', args: [host] }, @@ -121,6 +125,7 @@ describe('createDesktopModuleHubServices', () => { { name: 'skills.setPinned', args: ['user:skill', false, host] }, { name: 'skills.delete', args: ['user:skill', host] }, { name: 'skills.open', args: ['skill', 'directory', host] }, + { name: 'skills.locations.open', args: ['user:agents', { createIfMissing: true }, host] }, { name: 'scheduledTasks.list', args: [host] }, { name: 'scheduledTasks.create', args: [createInput, host] }, { name: 'scheduledTasks.update', args: ['task', updateInput, host] }, diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts index 6a31e8150e..a1d179ee31 100644 --- a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -104,6 +104,7 @@ function input( return { uiLocale: "en", active: true, + clientPathsAccessible: false, toastApi: toastRecorder(records), useSkillInChat: () => undefined, ...overrides, @@ -147,6 +148,16 @@ test("Skills projections have independent same-Host generation and default-Host sourceType: "local", }, ], + listLocations: async () => [ + { + ref: "project:agents", + scope: "project", + source: "agents", + path: "/project/.agents/skills", + status: "available", + skillCount: 1, + }, + ], listBundledCatalog: async () => [ { id: "bundled-a", @@ -183,6 +194,7 @@ test("Skills projections have independent same-Host generation and default-Host assert.equal(controller().revision, 2); assert.equal(controller().host.managedSkillSources[0]?.id, "source-a"); assert.equal(controller().host.bundledSkillCatalog[0]?.id, "bundled-a"); + assert.equal(controller().host.skillLocations[0]?.ref, "project:agents"); const lateHostRead = deferred(); services.skills.list = async () => lateHostRead.promise; @@ -213,6 +225,10 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca calls.push("list"); return []; }, + listLocations: async () => { + calls.push("locations"); + return []; + }, listManagedSources: async () => { calls.push("sources"); return []; @@ -237,7 +253,7 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca }, }); const activeInput = input(records, { - openSkillsFolder: () => undefined, + clientPathsAccessible: true, }); await act(async () => renderController(root, services, activeInput)); const importManagedSkillSource = @@ -261,11 +277,11 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca assert.deepEqual(calls.splice(0), ["sources"]); await act(async () => controller().host.onInstallManagedSkill("source-a")); - assert.deepEqual(calls.splice(0), ["list", "sources"]); + assert.deepEqual(calls.splice(0), ["list", "locations", "sources"]); assert.equal(records.at(-1)?.kind, "success"); await act(async () => controller().host.onInstallBundledSkill("bundled-a")); - assert.deepEqual(calls.splice(0), ["list", "catalog"]); + assert.deepEqual(calls.splice(0), ["list", "locations", "catalog"]); await act(async () => { assert.equal(await controller().host.onUpdateManagedSkill("managed"), true); @@ -281,7 +297,7 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca await act(async () => controller().host.onDeleteSkill("user:agents:bundled-a"), ); - assert.deepEqual(calls.splice(0), ["list", "catalog"]); + assert.deepEqual(calls.splice(0), ["list", "locations", "catalog"]); assert.match(records.at(-1)?.description ?? "", /bundled-a/); const lateInstall = deferred>(); @@ -309,7 +325,7 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = let defaultHost = hostA; const openFailure = deferred(); const used: string[] = []; - const opened: string[] = []; + const opened: Array<{ ref: string; createIfMissing: boolean }> = []; const defaults = createFakeModuleHubServices(); const services = createFakeModuleHubServices({ runtimeHosts: { @@ -319,6 +335,10 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = skills: { ...defaults.skills, open: async () => openFailure.promise, + openLocation: async (ref, options) => { + opened.push({ ref, createIfMissing: options.createIfMissing === true }); + return { ok: true }; + }, }, }); @@ -332,7 +352,7 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = ), ); assert.equal(controller().host.onOpenSkill, undefined); - assert.equal(controller().host.onOpenSkillsFolder, undefined); + assert.equal(controller().host.onOpenSkillLocation, undefined); assert.equal(controller().host.onImportManagedSkillSource, undefined); controller().host.onUseSkill("skill-a", "Skill A"); assert.deepEqual(used, ["skill-a:Skill A"]); @@ -343,9 +363,7 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = services, input(records, { useSkillInChat: () => undefined, - openSkillsFolder: () => { - opened.push("folder"); - }, + clientPathsAccessible: true, }), ), ); @@ -354,8 +372,11 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = typeof controller().host.onImportManagedSkillSource, "function", ); - controller().host.onOpenSkillsFolder?.(); - assert.deepEqual(opened, ["folder"]); + assert.equal(typeof controller().host.onOpenSkillLocation, "function"); + await act(async () => + controller().host.onOpenSkillLocation?.("user:agents", true), + ); + assert.deepEqual(opened, [{ ref: "user:agents", createIfMissing: true }]); const pendingOpen = controller().host.onOpenSkill?.("skill-a"); defaultHost = hostB; @@ -395,7 +416,7 @@ test("Skills errors recheck the active surface after an async Host fence", async }, }); - const capability = { openSkillsFolder: () => undefined }; + const capability = { clientPathsAccessible: true }; await act(async () => renderController(root, services, input(records, capability)), ); @@ -423,15 +444,17 @@ test("stale Skills refresh errors do not outlive a newer successful generation", const records: ToastRecord[] = []; const host = { profileId: "profile-a", hostId: "host-a" }; const staleHostRecheck = deferred(); + const staleLocationRead = deferred<[]>(); let hostReads = 0; let skillReads = 0; + let locationReads = 0; const defaults = createFakeModuleHubServices(); const services = createFakeModuleHubServices({ runtimeHosts: { ...defaults.runtimeHosts, getDefault: async () => { hostReads += 1; - return hostReads === 2 ? staleHostRecheck.promise : host; + return hostReads === 3 ? staleHostRecheck.promise : host; }, }, skills: { @@ -441,6 +464,10 @@ test("stale Skills refresh errors do not outlive a newer successful generation", if (skillReads === 1) throw new Error("stale refresh failed"); return [skill("fresh")]; }, + listLocations: async () => { + locationReads += 1; + return locationReads === 1 ? staleLocationRead.promise : []; + }, }, }); @@ -450,7 +477,7 @@ test("stale Skills refresh errors do not outlive a newer successful generation", await Promise.resolve(); await Promise.resolve(); }); - assert.equal(hostReads, 2); + assert.equal(hostReads, 3); await act(async () => controller().host.onRefreshSkills()); assert.deepEqual( @@ -459,6 +486,7 @@ test("stale Skills refresh errors do not outlive a newer successful generation", ); staleHostRecheck.resolve(host); + staleLocationRead.resolve([]); await act(async () => staleRefresh); assert.deepEqual(records, []); }); diff --git a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts index 772556b560..ac2c96d22a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts @@ -57,6 +57,7 @@ test("projects an empty Skill surface until a remote Project is selected", async "skills:listInvocable", "skills:catalog:list", "skills:sources:list", + "skills:locations:list", ]) { const handler = handlers.get(channel); assert.ok(handler, `missing ${channel} handler`); @@ -111,3 +112,34 @@ test("binds new-session Skill discovery to its explicit Project", async () => { permissionMode: "bypass", }); }); + +test("blocks Skill location opening for a remote Runtime Host", async () => { + const handlers = new Map(); + let opened = false; + registerRuntimeHostSkillsIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: {} as DesktopRuntimeHostClient, + workspaceRoot: "/client-workspace", + mainWindowController: {} as never, + getSelectedWorkspaceTarget: async () => ({ kind: "project", projectId: "remote" }), + resolveNewSessionWorkspaceTarget: async () => undefined, + getDefaultPermissionMode: async () => "ask", + openPath: async () => { + opened = true; + return ""; + }, + allowLocalPaths: false, + resolveLocale: async () => "en", + }); + + const handler = handlers.get("skills:locations:open"); + assert.ok(handler); + assert.deepEqual( + await handler({} as never, "user:agents", { createIfMissing: true }), + { ok: false, reason: "blocked_path" }, + ); + assert.equal(opened, false); +}); diff --git a/apps/desktop/src/main/__tests__/skill-locations.test.ts b/apps/desktop/src/main/__tests__/skill-locations.test.ts new file mode 100644 index 0000000000..018281a67a --- /dev/null +++ b/apps/desktop/src/main/__tests__/skill-locations.test.ts @@ -0,0 +1,136 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { lstat, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { listSkillLocations, resolveSkillLocation } from '../skill-locations.js'; + +test('lists every standard Skill location with availability and inventory counts', async () => { + await withFixture(async ({ projectRoot, workspaceRoot, homeDirectory }) => { + await writeSkill(join(projectRoot, '.agents', 'skills'), 'project-tool'); + await writeSkill(join(homeDirectory, '.agents', 'skills'), 'user-tool'); + + const locations = await listSkillLocations({ projectRoot, workspaceRoot, homeDirectory }); + + assert.deepEqual( + locations.map(({ ref, status, skillCount }) => ({ ref, status, skillCount })), + [ + { ref: 'project:maka', status: 'missing', skillCount: 0 }, + { ref: 'project:agents', status: 'available', skillCount: 1 }, + { ref: 'workspace:legacy', status: 'missing', skillCount: 0 }, + { ref: 'user:maka', status: 'missing', skillCount: 0 }, + { ref: 'user:agents', status: 'available', skillCount: 1 }, + ], + ); + }); +}); + +test('creates and resolves only an allowlisted missing Skill location', async () => { + await withFixture(async ({ projectRoot, workspaceRoot, homeDirectory }) => { + const context = { projectRoot, workspaceRoot, homeDirectory }; + + assert.deepEqual(await resolveSkillLocation(context, 'workspace:legacy', false), { + ok: false, + reason: 'missing', + }); + const created = await resolveSkillLocation(context, 'workspace:legacy', true); + assert.equal(created.ok, true); + if (created.ok) { + assert.equal(created.path, await realpath(join(workspaceRoot, 'skills'))); + assert.equal((await lstat(created.path)).isDirectory(), true); + } + assert.deepEqual(await resolveSkillLocation(context, '../outside', true), { + ok: false, + reason: 'unknown_location', + }); + }); +}); + +test('refuses to create through a symlinked Skill location ancestor', async () => { + await withFixture(async ({ projectRoot, workspaceRoot, homeDirectory, root }) => { + const outside = join(root, 'outside'); + await mkdir(outside); + await symlink(outside, join(projectRoot, '.maka')); + + assert.deepEqual( + await resolveSkillLocation( + { projectRoot, workspaceRoot, homeDirectory }, + 'project:maka', + true, + ), + { ok: false, reason: 'blocked_path' }, + ); + }); +}); + +test('accepts a Skill location whose ancestor symlink stays inside the containment root', async () => { + await withFixture(async ({ projectRoot, workspaceRoot, homeDirectory }) => { + const contained = join(projectRoot, 'contained'); + await mkdir(contained); + await symlink(contained, join(projectRoot, '.maka')); + + const created = await resolveSkillLocation( + { projectRoot, workspaceRoot, homeDirectory }, + 'project:maka', + true, + ); + + assert.equal(created.ok, true); + if (created.ok) { + assert.equal(created.path, await realpath(join(contained, 'skills'))); + } + }); +}); + +async function withFixture( + run: (fixture: { + root: string; + projectRoot: string; + workspaceRoot: string; + homeDirectory: string; + }) => Promise, +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-skill-locations-')); + const projectRoot = join(root, 'project'); + const workspaceRoot = join(root, 'workspace'); + const homeDirectory = join(root, 'home'); + await Promise.all([ + mkdir(projectRoot), + mkdir(workspaceRoot), + mkdir(homeDirectory), + ]); + try { + await run({ root, projectRoot, workspaceRoot, homeDirectory }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + +async function writeSkill(root: string, id: string): Promise { + const directory = join(root, id); + await mkdir(directory, { recursive: true }); + await writeFile( + join(directory, 'SKILL.md'), + `---\nname: ${id}\ndescription: ${id} description\n---\n# ${id}\n`, + 'utf8', + ); +} diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index d019762473..629df314d6 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -36,6 +36,7 @@ import type { ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails, + SkillLocation, } from "@maka/ui"; import type { createMainWindowController } from "./main-window.js"; import { @@ -49,6 +50,7 @@ import type { import type { UiLocale } from "@maka/core/ui-locale"; import { nativeFileDialogCopy } from "./native-file-dialog-copy.js"; import { resolveSkillOpenPath } from "./skill-open-path.js"; +import { listSkillLocations, resolveSkillLocation } from "./skill-locations.js"; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -181,6 +183,41 @@ export function registerRuntimeHostSkillsIpc( ); }); + handleReconnectableRead(deps.ipcMain, "skills:locations:list", async (): Promise => { + if (deps.allowLocalPaths === false) return []; + const workspace = await deps.getSelectedWorkspaceTarget(); + if (!workspace) return []; + const snapshot = await deps.client.loadSkillCatalog({ workspace }, "governance"); + return listSkillLocations({ + projectRoot: snapshot.workspace.hostCwd, + workspaceRoot: deps.workspaceRoot, + }); + }); + + deps.ipcMain.handle( + "skills:locations:open", + async (_event, ref: string, options?: { createIfMissing?: unknown }) => { + if (deps.allowLocalPaths === false) { + return { ok: false as const, reason: "blocked_path" as const }; + } + const workspace = await requireSelectedWorkspaceTarget(deps); + const snapshot = await deps.client.loadSkillCatalog({ workspace }, "governance"); + const resolved = await resolveSkillLocation( + { + projectRoot: snapshot.workspace.hostCwd, + workspaceRoot: deps.workspaceRoot, + }, + ref, + options?.createIfMissing === true, + ); + if (!resolved.ok) return resolved; + const error = await deps.openPath(resolved.path); + return error + ? { ok: false as const, reason: "open_failed" as const } + : { ok: true as const }; + }, + ); + deps.ipcMain.handle("skills:sources:importLocalFile", async () => { if (deps.allowLocalPaths === false) { throw new Error("Local Skill import is unavailable for a remote Runtime Host"); diff --git a/apps/desktop/src/main/skill-locations.ts b/apps/desktop/src/main/skill-locations.ts new file mode 100644 index 0000000000..7645440368 --- /dev/null +++ b/apps/desktop/src/main/skill-locations.ts @@ -0,0 +1,173 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { lstat, mkdir, realpath } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { resolve } from 'node:path'; +import { isPathInside, realpathAllowMissing } from '@maka/runtime/path-containment'; +import { + resolveSkillDiscoveryPaths, + scanSkillsWithDiagnostics, + type SkillDiscoveryEntry, +} from '@maka/runtime/skills'; +import type { SkillLocation, SkillLocationRef } from '@maka/ui'; + +export type ResolveSkillLocationResult = + | { readonly ok: true; readonly path: string } + | { + readonly ok: false; + readonly reason: 'unknown_location' | 'missing' | 'blocked_path' | 'create_failed'; + }; + +export interface SkillLocationContext { + readonly projectRoot: string; + readonly workspaceRoot: string; + readonly homeDirectory?: string; +} + +export async function listSkillLocations( + context: SkillLocationContext, +): Promise { + const discovery = resolveSkillDiscoveryPaths( + context.projectRoot, + context.workspaceRoot, + context.homeDirectory ?? homedir(), + ); + const scan = await scanSkillsWithDiagnostics(discovery); + const counts = new Map(); + for (const skill of [...scan.inventory, ...scan.rejected]) { + const ref = skillLocationRef(skill); + counts.set(ref, (counts.get(ref) ?? 0) + 1); + } + return Promise.all(discovery.entries.map(async (entry) => { + const ref = skillLocationRef(entry); + const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); + return { + ref, + scope: entry.scope as SkillLocation['scope'], + source: entry.source as SkillLocation['source'], + path: entry.dir, + status: inspected.status, + skillCount: counts.get(ref) ?? 0, + }; + })); +} + +export async function resolveSkillLocation( + context: SkillLocationContext, + ref: string, + createIfMissing: boolean, +): Promise { + const discovery = resolveSkillDiscoveryPaths( + context.projectRoot, + context.workspaceRoot, + context.homeDirectory ?? homedir(), + ); + const entry = discovery.entries.find((candidate) => skillLocationRef(candidate) === ref); + if (!entry) return { ok: false, reason: 'unknown_location' }; + + const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); + if (inspected.status === 'available') return { ok: true, path: inspected.path }; + if (inspected.status === 'blocked_path') return { ok: false, reason: 'blocked_path' }; + if (inspected.status === 'read_failed') return { ok: false, reason: 'create_failed' }; + if (!createIfMissing) return { ok: false, reason: 'missing' }; + + try { + return { ok: true, path: await ensureContainedDirectory(entry.containmentRoot, entry.dir) }; + } catch { + return { ok: false, reason: 'create_failed' }; + } +} + +function skillLocationRef( + entry: Pick, +): SkillLocationRef { + return `${entry.scope}:${entry.source}` as SkillLocationRef; +} + +async function inspectDirectory( + containmentRoot: string, + target: string, +): Promise< + | { readonly status: 'available'; readonly path: string } + | { readonly status: 'missing' | 'blocked_path' | 'read_failed' } +> { + if (!isPathInside(resolve(containmentRoot), resolve(target))) { + return { status: 'blocked_path' }; + } + + let rootReal: string; + try { + rootReal = await realpath(containmentRoot); + } catch { + return { status: 'read_failed' }; + } + + let metadata; + try { + metadata = await lstat(target); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + return { status: 'read_failed' }; + } + try { + const targetReal = await realpathAllowMissing(target); + return isPathInside(rootReal, targetReal) + ? { status: 'missing' } + : { status: 'blocked_path' }; + } catch { + return { status: 'read_failed' }; + } + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + return { status: 'blocked_path' }; + } + try { + const targetReal = await realpath(target); + return isPathInside(rootReal, targetReal) + ? { status: 'available', path: targetReal } + : { status: 'blocked_path' }; + } catch { + return { status: 'read_failed' }; + } +} + +async function ensureContainedDirectory( + containmentRoot: string, + target: string, +): Promise { + if (!isPathInside(resolve(containmentRoot), resolve(target))) { + throw new Error('Skill location escaped its root'); + } + const rootReal = await realpath(containmentRoot); + const targetReal = await realpathAllowMissing(target); + if (!isPathInside(rootReal, targetReal)) { + throw new Error('Skill location escaped its root'); + } + await mkdir(targetReal, { recursive: true, mode: 0o700 }); + const [metadata, createdReal] = await Promise.all([lstat(target), realpath(target)]); + if ( + !metadata.isDirectory() + || metadata.isSymbolicLink() + || !isPathInside(rootReal, createdReal) + ) { + throw new Error('Skill location path must resolve to a contained directory'); + } + return createdReal; +} diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index ec50637910..387304af9b 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -251,7 +251,7 @@ import type { } from '@maka/runtime/stream-graph-read-model'; import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; -import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from '@maka/ui'; +import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillLocation, SkillLocationRef } from '@maka/ui'; import type { ConfigCategory } from '@maka/storage/config-transfer'; import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from '@maka/core/onboarding'; import type { @@ -1902,6 +1902,13 @@ export interface MakaBridge { | { ok: false; reason: 'cancelled' | 'invalid_skill' | 'already_exists' | 'blocked_path' | 'write_failed' } >; }; + locations: { + list(host?: DesktopRuntimeHostRef): Promise; + open(ref: SkillLocationRef, options?: { createIfMissing?: boolean }, host?: DesktopRuntimeHostRef): Promise< + | { ok: true } + | { ok: false; reason: 'unknown_location' | 'missing' | 'blocked_path' | 'create_failed' | 'open_failed' } + >; + }; installManaged(sourceId: string, host?: DesktopRuntimeHostRef): Promise< | { ok: true; skill: SkillEntry } | { ok: false; reason: 'not_found' | 'already_exists' | 'blocked_path' | 'write_failed' } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 21f105fdb9..0e3bb98915 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3864,6 +3864,14 @@ const makaBridge = { return invokeSelectedRuntimeHost(host, 'skills:sources:importLocalFile'); }, }, + locations: { + list(host?: DesktopRuntimeHostRef) { + return invokeSelectedRuntimeHost(host, 'skills:locations:list'); + }, + open(ref: import('@maka/ui').SkillLocationRef, options?: { createIfMissing?: boolean }, host?: DesktopRuntimeHostRef) { + return invokeSelectedRuntimeHost(host, 'skills:locations:open', ref, options); + }, + }, installManaged(sourceId: string, host?: DesktopRuntimeHostRef): Promise< | { ok: true; skill: SkillEntry } | { ok: false; reason: 'not_found' | 'already_exists' | 'blocked_path' | 'write_failed' } diff --git a/apps/desktop/src/renderer/app-shell-command-actions.ts b/apps/desktop/src/renderer/app-shell-command-actions.ts index 6e91cca05c..0328cfd03b 100644 --- a/apps/desktop/src/renderer/app-shell-command-actions.ts +++ b/apps/desktop/src/renderer/app-shell-command-actions.ts @@ -88,7 +88,6 @@ export interface AppShellCommandListOptions { openSessionInChat: (sessionId: string) => void; openSettings: () => void; openSettingsSection: (section: SettingsSection) => void; - openSkillsFolder: () => Promise; openWorkspaceFolder: () => Promise; refreshConnections: () => Promise; copyTodayDailyReview: () => Promise; @@ -220,7 +219,6 @@ export function buildAppShellCommandList( ...(options.clientPathsAccessible ? { onOpenProjectFolder: () => optionsRef.current.openProjectFolder(), - onOpenSkillsFolder: () => optionsRef.current.openSkillsFolder(), } : {}), onSelectModule: (selection) => { diff --git a/apps/desktop/src/renderer/app-shell-project-actions.ts b/apps/desktop/src/renderer/app-shell-project-actions.ts index 304e635416..3c1b991a13 100644 --- a/apps/desktop/src/renderer/app-shell-project-actions.ts +++ b/apps/desktop/src/renderer/app-shell-project-actions.ts @@ -68,7 +68,6 @@ export interface AppShellProjectActions { restoreProject(projectId: string): Promise; openProjectFolder(): Promise; openWorkspaceFolder(): Promise; - openSkillsFolder(): Promise; } export function createAppShellProjectActions(deps: { @@ -322,28 +321,6 @@ export function createAppShellProjectActions(deps: { } } - async function openSkillsFolder() { - try { - const { value: result, diagnosticTarget } = await runOnDefaultRuntimeHost((host) => - window.maka.app.openPath('skills', undefined, host), - ); - if (!result.ok) { - toastApi.error( - copy.openFailedTitle(openPathActionLabel('skills', uiLocale)), - openPathFailureCopy(result.reason, uiLocale), - undefined, - diagnosticTarget, - ); - } - } catch (error) { - showDefaultProjectError( - copy.openFailedTitle(openPathActionLabel('skills', uiLocale)), - openPathActionErrorMessage(error, 'skills', uiLocale), - error, - ); - } - } - async function openProjectFolder() { try { const { value: result, diagnosticTarget } = sessionId @@ -411,6 +388,5 @@ export function createAppShellProjectActions(deps: { restoreProject, openProjectFolder, openWorkspaceFolder, - openSkillsFolder, }; } diff --git a/apps/desktop/src/renderer/app-shell.tsx b/apps/desktop/src/renderer/app-shell.tsx index 47068f73dc..06e2857863 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1167,7 +1167,6 @@ function AppShellContent({ restoreProject, openProjectFolder, openWorkspaceFolder, - openSkillsFolder, } = useAppShellProjectContext({ uiLocale, rendererMountedRef, @@ -2203,7 +2202,6 @@ function AppShellContent({ openSideConversation: () => commands.openTool('side-chat'), openSettings, openSettingsSection, - openSkillsFolder, openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, copyTodayDailyReview: moduleHubCommands.copyTodayDailyReview, @@ -2240,7 +2238,7 @@ function AppShellContent({ composerRef.current?.appendText(text)} diff --git a/apps/desktop/src/renderer/command-palette-commands.ts b/apps/desktop/src/renderer/command-palette-commands.ts index b9a020bec0..3948a3d815 100644 --- a/apps/desktop/src/renderer/command-palette-commands.ts +++ b/apps/desktop/src/renderer/command-palette-commands.ts @@ -84,7 +84,6 @@ export function buildCommandList(args: { onSetDefaultConnection?(slug: string): Promise | void; onOpenWorkspace?(): Promise | void; onOpenProjectFolder?(): Promise | void; - onOpenSkillsFolder?(): Promise | void; /** Copy the active conversation as Markdown to the clipboard. */ onExportActiveConversation?(): Promise | void; /** @@ -323,16 +322,6 @@ export function buildCommandList(args: { run: () => args.onOpenProjectFolder!(), }); } - if (args.onOpenSkillsFolder) { - cmds.push({ - id: 'diag:open-skills', - kind: 'action', - ...staticCopy('diag:open-skills'), - Icon: FolderOpen, - keywords: [...copy.staticKeywords['diag:open-skills']], - run: () => args.onOpenSkillsFolder!(), - }); - } if (args.onExportActiveConversation && args.activeSessionId) { cmds.push({ id: 'diag:export-conversation', diff --git a/apps/desktop/src/renderer/features/module-hub/README.md b/apps/desktop/src/renderer/features/module-hub/README.md index 98e5e15896..c6b56e0ecc 100644 --- a/apps/desktop/src/renderer/features/module-hub/README.md +++ b/apps/desktop/src/renderer/features/module-hub/README.md @@ -22,7 +22,7 @@ `module-hub` owns the Desktop renderer behavior behind Extensions and Automations: -- installed, managed-source, and bundled-catalog Skills projections and +- installed, location, managed-source, and bundled-catalog Skills projections and mutations; - Scheduled Tasks projection, mutations, due/change subscriptions, and the create-dialog request nonce; @@ -65,7 +65,7 @@ reach it only through `testing.ts`. ## Lifecycle invariants -- The three Skills projections and Scheduled Tasks each have independent +- The four Skills projections and Scheduled Tasks each have independent generation fences. - Host-scoped reads re-check the current default Runtime Host before committing. Late reads, mutation feedback, and diagnostics from an old Host are dropped. diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts index 209c325382..6b5b2bdeb5 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-module-hub-controller.ts @@ -72,7 +72,7 @@ export interface ModuleHubController { export interface UseModuleHubControllerInput { readonly selection: NavSelection; readonly selectModule: (selection: NavSelection) => void; - readonly openSkillsFolder?: () => void | Promise; + readonly clientPathsAccessible: boolean; readonly useSkillInChat: (skillId: string, skillName: string) => void; readonly openSession: (sessionId: string) => void; readonly appendComposerText: (text: string) => void; @@ -94,7 +94,7 @@ export function useModuleHubController( active: isSkillsActive, toastApi, useSkillInChat: input.useSkillInChat, - openSkillsFolder: input.openSkillsFolder, + clientPathsAccessible: input.clientPathsAccessible, }); const scheduledTasks = useScheduledTasksController({ uiLocale, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index 3871f35609..8246f7565b 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -24,6 +24,8 @@ import type { ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, + SkillLocation, + SkillLocationRef, ToastApi, } from "@maka/ui"; import { @@ -48,12 +50,16 @@ type RefreshOptions = { export interface SkillsHostModel { skills: SkillEntry[]; + skillLocations: SkillLocation[]; managedSkillSources: ManagedSkillSourceEntry[]; bundledSkillCatalog: BundledSkillCatalogEntry[]; onRefreshSkills(): Promise; onOpenSkill?: (skillId: string) => Promise; onUseSkill(skillId: string, skillName: string): void; - onOpenSkillsFolder?: () => void | Promise; + onOpenSkillLocation?: ( + ref: SkillLocationRef, + createIfMissing: boolean, + ) => Promise; onRefreshManagedSkillSources(): Promise; onImportManagedSkillSource?: () => Promise; onInstallManagedSkill(sourceId: string): Promise; @@ -88,18 +94,22 @@ export interface UseSkillsControllerInput { active: boolean; toastApi: SkillsToastApi; useSkillInChat(skillId: string, skillName: string): void; - openSkillsFolder?: () => void | Promise; + clientPathsAccessible: boolean; } type SkillsProjection = - "skills" | "managedSkillSources" | "bundledSkillCatalog"; + | "skills" + | "skillLocations" + | "managedSkillSources" + | "bundledSkillCatalog"; -/** Owns the three Skills projections, their Host fences, and every Skills mutation. */ +/** Owns the Skills projections, their Host fences, and every Skills mutation. */ export function useSkillsController( input: UseSkillsControllerInput, ): SkillsController { const services = useModuleHubServices(); const [skills, setSkills] = useState([]); + const [skillLocations, setSkillLocations] = useState([]); const [revision, setRevision] = useState(0); const [managedSkillSources, setManagedSkillSources] = useState< ManagedSkillSourceEntry[] @@ -109,6 +119,7 @@ export function useSkillsController( >([]); const generationsRef = useRef>({ skills: 0, + skillLocations: 0, managedSkillSources: 0, bundledSkillCatalog: 0, }); @@ -121,6 +132,7 @@ export function useSkillsController( return () => { mountedRef.current = false; generationsRef.current.skills += 1; + generationsRef.current.skillLocations += 1; generationsRef.current.managedSkillSources += 1; generationsRef.current.bundledSkillCatalog += 1; }; @@ -267,6 +279,56 @@ export function useSkillsController( ], ); + const refreshSkillLocations = useCallback( + async (options: RefreshOptions = {}): Promise => { + const generation = ++generationsRef.current.skillLocations; + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.listLocations(host), + ); + await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + next.host, + () => { + if ( + mountedRef.current && + generation === generationsRef.current.skillLocations + ) { + setSkillLocations(next.value); + } + }, + ); + } catch (error) { + if ( + !mountedRef.current || + generation !== generationsRef.current.skillLocations + ) { + return; + } + const shouldReport = await shouldReportRefreshError(options, error); + if ( + mountedRef.current && + generation === generationsRef.current.skillLocations && + shouldReport + ) { + reportRuntimeHostError( + copy.refreshLocationsFailedTitle, + copy.refreshLocationsFallback, + error, + ); + } + } + }, + [ + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportRefreshError, + ], + ); + const refreshBundledSkillCatalog = useCallback( async (options: RefreshOptions = {}): Promise => { const generation = ++generationsRef.current.bundledSkillCatalog; @@ -412,6 +474,7 @@ export function useSkillsController( return; } await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); await refreshManagedSkillSources({ shouldShowError: isSkillsSurfaceActive, }); @@ -434,6 +497,7 @@ export function useSkillsController( [ isSkillsSurfaceActive, refreshManagedSkillSources, + refreshSkillLocations, refreshSkills, reportRuntimeHostError, services.runtimeHosts, @@ -463,6 +527,7 @@ export function useSkillsController( return; } await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); await refreshBundledSkillCatalog({ shouldShowError: isSkillsSurfaceActive, }); @@ -485,6 +550,7 @@ export function useSkillsController( [ isSkillsSurfaceActive, refreshBundledSkillCatalog, + refreshSkillLocations, refreshSkills, reportRuntimeHostError, services.runtimeHosts, @@ -707,6 +773,7 @@ export function useSkillsController( return; } await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); await refreshBundledSkillCatalog({ shouldShowError: isSkillsSurfaceActive, }); @@ -730,6 +797,7 @@ export function useSkillsController( [ isSkillsSurfaceActive, refreshBundledSkillCatalog, + refreshSkillLocations, refreshSkills, reportRuntimeHostError, services.runtimeHosts, @@ -775,25 +843,75 @@ export function useSkillsController( ], ); + const openSkillLocation = useCallback( + async ( + ref: SkillLocationRef, + createIfMissing: boolean, + ): Promise => { + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + (host) => services.skills.openLocation(ref, { createIfMissing }, host), + ); + if (!next.value.ok) { + if (await shouldReportMutation(next.host)) { + inputRef.current.toastApi.error( + copy.openLocationFailedTitle, + copy.openLocationFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + } + return; + } + if (createIfMissing) { + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); + } + } catch (error) { + if (await shouldReportOperationError(error)) { + reportRuntimeHostError( + copy.openLocationFailedTitle, + copy.openLocationFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshSkillLocations, + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportOperationError, + shouldReportMutation, + ], + ); + const refreshProjectSkills = useCallback(async (): Promise => { await Promise.all([ refreshSkills(), + refreshSkillLocations(), refreshManagedSkillSources(), refreshBundledSkillCatalog(), ]); - }, [refreshBundledSkillCatalog, refreshManagedSkillSources, refreshSkills]); + }, [refreshBundledSkillCatalog, refreshManagedSkillSources, refreshSkillLocations, refreshSkills]); const host = useMemo( () => ({ skills, + skillLocations, managedSkillSources, bundledSkillCatalog, - onRefreshSkills: refreshSkills, + onRefreshSkills: async () => { + await Promise.all([refreshSkills(), refreshSkillLocations()]); + }, onUseSkill: input.useSkillInChat, - ...(input.openSkillsFolder + ...(input.clientPathsAccessible ? { onOpenSkill: openSkill, - onOpenSkillsFolder: input.openSkillsFolder, + onOpenSkillLocation: openSkillLocation, onImportManagedSkillSource: importManagedSkillSource, } : {}), @@ -811,15 +929,18 @@ export function useSkillsController( bundledSkillCatalog, deleteSkill, importManagedSkillSource, - input.openSkillsFolder, + input.clientPathsAccessible, input.useSkillInChat, installBundledSkill, installManagedSkill, managedSkillSources, openSkill, + openSkillLocation, previewManagedSkillUpdate, refreshBundledSkillCatalog, + refreshSkillLocations, refreshManagedSkillSources, + skillLocations, refreshSkills, setSkillEnabled, setSkillPinned, diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts index 7abb633628..53a0cafd61 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -34,6 +34,8 @@ import type { ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, + SkillLocation, + SkillLocationRef, } from '@maka/ui'; export type ModuleHubUnsubscribe = () => void; @@ -129,8 +131,21 @@ export type OpenSkillResult = | 'open_failed'; }; +export type OpenSkillLocationResult = + | { ok: true } + | { + ok: false; + reason: + | 'unknown_location' + | 'missing' + | 'blocked_path' + | 'create_failed' + | 'open_failed'; + }; + export interface ModuleHubSkillsService { list(host: ModuleHubRuntimeHostRef): Promise; + listLocations(host: ModuleHubRuntimeHostRef): Promise; listManagedSources(host: ModuleHubRuntimeHostRef): Promise; listBundledCatalog(host: ModuleHubRuntimeHostRef): Promise; importManagedSource(host: ModuleHubRuntimeHostRef): Promise; @@ -165,6 +180,11 @@ export interface ModuleHubSkillsService { target: 'file' | 'directory', host: ModuleHubRuntimeHostRef, ): Promise; + openLocation( + ref: SkillLocationRef, + options: { createIfMissing?: boolean }, + host: ModuleHubRuntimeHostRef, + ): Promise; } export type ScheduledTaskCreateInput = Omit; diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index 8dbc0a4223..21a192fb44 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -75,6 +75,7 @@ export function createFakeModuleHubHostModel( selectModule: () => undefined, skills: { skills: [], + skillLocations: [], managedSkillSources: [], bundledSkillCatalog: [], onRefreshSkills: async () => undefined, @@ -137,6 +138,7 @@ export function createFakeModuleHubServices( }, skills: { list: async () => [], + listLocations: async () => [], listManagedSources: async () => [], listBundledCatalog: async () => [], importManagedSource: async () => @@ -149,6 +151,7 @@ export function createFakeModuleHubServices( setPinned: async () => notConfigured("skills.setPinned"), delete: async () => notConfigured("skills.delete"), open: async () => notConfigured("skills.open"), + openLocation: async () => notConfigured("skills.openLocation"), }, scheduledTasks: { list: async () => [], diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index b5abb54eb9..954d8158c3 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -51,7 +51,6 @@ export const STATIC_COMMAND_IDS = [ 'nav:daily-review', 'diag:open-workspace', 'diag:open-project-folder', - 'diag:open-skills', 'diag:export-conversation', 'diag:save-conversation-file', 'diag:copy-today-daily-review', @@ -101,7 +100,6 @@ const STATIC_COMMAND_KEYWORDS: Record = { 'nav:daily-review': ['daily', 'review', 'today', '每日', '回顾', '今天'], 'diag:open-workspace': ['workspace', 'folder', 'open', 'finder', '工作区', '文件夹', '目录'], 'diag:open-project-folder': ['project', 'folder', 'open', 'finder', '项目', '目录', '文件夹'], - 'diag:open-skills': ['skills', 'folder', 'open', 'finder', '技能', '文件夹'], 'diag:export-conversation': ['export', 'markdown', 'copy', 'conversation', '导出', '任务', '剪贴板', 'md'], 'diag:save-conversation-file': [ 'save', @@ -292,6 +290,8 @@ type ShellCopy = { skillActions: { refreshSkillsFailedTitle: string; refreshSkillsFallback: string; + refreshLocationsFailedTitle: string; + refreshLocationsFallback: string; refreshSourcesFailedTitle: string; refreshSourcesFallback: string; refreshBundledFailedTitle: string; @@ -326,6 +326,12 @@ type ShellCopy = { deletedDescription(id: string): string; openFailedTitle: string; openFallback: string; + openLocationFailedTitle: string; + openLocationFallback: string; + openLocationFailures: Record< + 'unknown_location' | 'missing' | 'blocked_path' | 'create_failed' | 'open_failed', + string + >; openFailures: Record< 'invalid_id' | 'missing' | 'blocked_path' | 'not_file' | 'not_directory' | 'open_failed', string @@ -569,11 +575,6 @@ const ZH_STATIC_COMMANDS: Record = { hint: 'Finder', group: '诊断', }, - 'diag:open-skills': { - label: '打开 Skills 文件夹', - hint: 'Finder', - group: '诊断', - }, 'diag:export-conversation': { label: '导出当前任务为 Markdown', hint: '复制到剪贴板', @@ -668,11 +669,6 @@ const EN_STATIC_COMMANDS: Record = { hint: 'Finder', group: 'Diagnostics', }, - 'diag:open-skills': { - label: 'Open Skills folder', - hint: 'Finder', - group: 'Diagnostics', - }, 'diag:export-conversation': { label: 'Copy task as Markdown', hint: 'Copy to clipboard', @@ -915,6 +911,8 @@ const SHELL_COPY_BY_LOCALE = { skillActions: { refreshSkillsFailedTitle: '刷新技能失败', refreshSkillsFallback: '刷新技能失败,请稍后重试。', + refreshLocationsFailedTitle: '刷新技能位置失败', + refreshLocationsFallback: '刷新技能位置失败,请稍后重试。', refreshSourcesFailedTitle: '刷新来源库失败', refreshSourcesFallback: '刷新来源库失败,请稍后重试。', refreshBundledFailedTitle: '刷新内置技能失败', @@ -949,6 +947,15 @@ const SHELL_COPY_BY_LOCALE = { deletedDescription: (id: string) => `${id} 已移除。`, openFailedTitle: '无法打开 Skill', openFallback: '无法打开 Skill,请稍后重试。', + openLocationFailedTitle: '无法打开技能位置', + openLocationFallback: '无法打开技能位置,请稍后重试。', + openLocationFailures: { + unknown_location: '这个技能位置无效。', + missing: '目录不存在。', + blocked_path: '技能位置不在允许范围内,已阻止打开。', + create_failed: '无法创建技能目录,请检查文件权限。', + open_failed: '系统打开目录失败。', + }, openFailures: { invalid_id: 'Skill 名称不在允许范围内。', missing: '没有找到对应的 SKILL.md。', @@ -1422,6 +1429,8 @@ const SHELL_COPY_BY_LOCALE = { skillActions: { refreshSkillsFailedTitle: '重新整理技能失敗', refreshSkillsFallback: '重新整理技能失敗,請稍後重試。', + refreshLocationsFailedTitle: '重新整理技能位置失敗', + refreshLocationsFallback: '重新整理技能位置失敗,請稍後重試。', refreshSourcesFailedTitle: '重新整理來源庫失敗', refreshSourcesFallback: '重新整理來源庫失敗,請稍後重試。', refreshBundledFailedTitle: '重新整理內建技能失敗', @@ -1456,6 +1465,15 @@ const SHELL_COPY_BY_LOCALE = { deletedDescription: (id: string) => `${id} 已移除。`, openFailedTitle: '無法開啟 Skill', openFallback: '無法開啟 Skill,請稍後重試。', + openLocationFailedTitle: '無法開啟技能位置', + openLocationFallback: '無法開啟技能位置,請稍後重試。', + openLocationFailures: { + unknown_location: '這個技能位置無效。', + missing: '目錄不存在。', + blocked_path: '技能位置不在允許範圍內,已阻止開啟。', + create_failed: '無法建立技能目錄,請檢查檔案權限。', + open_failed: '系統無法開啟目錄。', + }, openFailures: { invalid_id: 'Skill 名稱不在允許範圍內。', missing: '沒有找到對應的 SKILL.md。', @@ -1934,6 +1952,8 @@ const SHELL_COPY_BY_LOCALE = { skillActions: { refreshSkillsFailedTitle: 'Could not refresh Skills', refreshSkillsFallback: 'Skills could not be refreshed. Try again later.', + refreshLocationsFailedTitle: 'Could not refresh Skill locations', + refreshLocationsFallback: 'Skill locations could not be refreshed. Try again later.', refreshSourcesFailedTitle: 'Could not refresh Skill sources', refreshSourcesFallback: 'Skill sources could not be refreshed. Try again later.', refreshBundledFailedTitle: 'Could not refresh built-in Skills', @@ -1968,6 +1988,15 @@ const SHELL_COPY_BY_LOCALE = { deletedDescription: (id: string) => `${id} was removed.`, openFailedTitle: 'Could not open Skill', openFallback: 'The Skill could not be opened. Try again later.', + openLocationFailedTitle: 'Could not open Skill location', + openLocationFallback: 'The Skill location could not be opened. Try again later.', + openLocationFailures: { + unknown_location: 'This Skill location is invalid.', + missing: 'The folder does not exist.', + blocked_path: 'The Skill location is outside the allowed paths, so opening was blocked.', + create_failed: 'The Skill folder could not be created. Check file permissions.', + open_failed: 'The system could not open the folder.', + }, openFailures: { invalid_id: 'The Skill name is not allowed.', missing: 'The matching SKILL.md was not found.', diff --git a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts index c5f5a90e5d..850f84b1a7 100644 --- a/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts +++ b/apps/desktop/src/renderer/platform/desktop/create-module-hub-services.ts @@ -67,6 +67,7 @@ export function createDesktopModuleHubServices( }, skills: { list: (host) => bridge.skills.list(host), + listLocations: (host) => bridge.skills.locations.list(host), listManagedSources: (host) => bridge.skills.sources.list(host), listBundledCatalog: (host) => bridge.skills.catalog.list(host), importManagedSource: (host) => bridge.skills.sources.importLocalFile(host), @@ -83,6 +84,7 @@ export function createDesktopModuleHubServices( bridge.skills.setPinned(skillRef, pinned, host), delete: (skillRef, host) => bridge.skills.delete(skillRef, host), open: (skillId, target, host) => bridge.skills.open(skillId, target, host), + openLocation: (ref, options, host) => bridge.skills.locations.open(ref, options, host), }, scheduledTasks: bridge.scheduledTasks, clientSettings: { diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 762c0bef7d..804a7476ac 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -698,6 +698,13 @@ function ExtensionsSkillsSurface(props: { badge: {}} />, }} skills={props.skills ?? []} + skillLocations={[ + { ref: 'project:maka', scope: 'project', source: 'maka', path: '/project/.maka/skills', status: 'missing', skillCount: 0 }, + { ref: 'project:agents', scope: 'project', source: 'agents', path: '/project/.agents/skills', status: 'available', skillCount: 2 }, + { ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', path: '/workspace/skills', status: 'missing', skillCount: 0 }, + { ref: 'user:maka', scope: 'user', source: 'maka', path: '~/.maka/skills', status: 'available', skillCount: 1 }, + { ref: 'user:agents', scope: 'user', source: 'agents', path: '~/.agents/skills', status: 'available', skillCount: 4 }, + ]} managedSkillSources={[]} bundledSkillCatalog={props.bundledSkillCatalog ?? []} onRefreshSkills={noop} @@ -705,7 +712,7 @@ function ExtensionsSkillsSurface(props: { onRefreshBundledSkillCatalog={noop} onOpenSkill={noop} onUseSkill={noop} - onOpenSkillsFolder={noop} + onOpenSkillLocation={noop} onInstallBundledSkill={noop} onPreviewManagedSkillUpdate={async (skillId) => ( skillId === UPDATE_AVAILABLE_PREVIEW.skill.id ? UPDATE_AVAILABLE_PREVIEW : null @@ -844,6 +851,7 @@ function ProductionModuleHubHostSurface() { [] = []; + +afterEach(async () => { + for (const root of mountedRoots.splice(0)) await act(() => root.unmount()); + Object.assign(globalThis, { + ...originalGlobals, + IS_REACT_ACT_ENVIRONMENT: originalActEnvironment, + }); +}); + +const locations: SkillLocation[] = [ + { ref: 'project:maka', scope: 'project', source: 'maka', path: '/repo/.maka/skills', status: 'missing', skillCount: 0 }, + { ref: 'project:agents', scope: 'project', source: 'agents', path: '/repo/.agents/skills', status: 'available', skillCount: 2 }, + { ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', path: '/workspace/skills', status: 'read_failed', skillCount: 0 }, + { ref: 'user:maka', scope: 'user', source: 'maka', path: '/home/user/.maka/skills', status: 'blocked_path', skillCount: 0 }, + { ref: 'user:agents', scope: 'user', source: 'agents', path: '/home/user/.agents/skills', status: 'available', skillCount: 1 }, +]; + +test('Skill locations expose all supported paths and create only a missing directory', async () => { + const { document, window } = parseHTML('
'); + window.getComputedStyle = () => ({ + direction: 'ltr', + writingMode: 'horizontal-tb', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: (callback: FrameRequestCallback) => { + callback(0); + return 1; + }, + cancelAnimationFrame() {}, + IS_REACT_ACT_ENVIRONMENT: true, + }); + + const opened: Array<{ ref: SkillLocationRef; createIfMissing: boolean }> = []; + const container = document.querySelector('#root'); + assert.ok(container); + const root = createRoot(container); + mountedRoots.push(root); + await act(() => { + root.render( + + + { + opened.push({ ref, createIfMissing }); + }} + /> + + , + ); + }); + + await clickByLabel(document, window, 'More Skill actions'); + await clickMenuItem(document, window, 'Skill locations…'); + + const markup = document.documentElement.innerHTML; + for (const location of locations) assert.ok(markup.includes(location.path)); + assert.ok(markup.includes('Create and open')); + assert.ok(markup.includes('2 Skills')); + assert.match(menuItem(document, 'Workspace compatibility folder').outerHTML, /aria-disabled="true"/); + assert.match(menuItem(document, 'User · Maka').outerHTML, /aria-disabled="true"/); + + await clickMenuItem(document, window, 'Project · Maka'); + assert.deepEqual(opened, [{ ref: 'project:maka', createIfMissing: true }]); +}); + +async function clickByLabel( + document: Document, + window: ReturnType['window'], + label: string, +): Promise { + const element = document.querySelector(`[aria-label="${label}"]`); + assert.ok(element, `missing ${label}`); + await act(async () => { + element.dispatchEvent(new window.Event('click', { bubbles: true })); + await Promise.resolve(); + }); +} + +async function clickMenuItem( + document: Document, + window: ReturnType['window'], + label: string, +): Promise { + const element = menuItem(document, label); + await act(async () => { + element.dispatchEvent(new window.Event('click', { bubbles: true })); + await Promise.resolve(); + }); +} + +function menuItem(document: Document, label: string): HTMLElement { + const element = Array.from(document.querySelectorAll('[role="menuitem"]')) + .find((candidate) => candidate.textContent?.includes(label)); + assert.ok(element, `missing menu item ${label}`); + return element; +} diff --git a/packages/ui/src/components.tsx b/packages/ui/src/components.tsx index 4c8d2ffe35..94155e6145 100644 --- a/packages/ui/src/components.tsx +++ b/packages/ui/src/components.tsx @@ -43,7 +43,7 @@ export { useSidebarUpdateProjection, type SidebarUpdateProjection, } from './sidebar-update-projection-context.js'; -export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails } from './module-panel-types.js'; +export type { BundledSkillCatalogEntry, DailyReviewMarkdownActionInput, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails, SkillLocation, SkillLocationRef } from './module-panel-types.js'; export { describeLoadToolResult, formatRedactedJson, formatToolIntent, loadToolDisplayName } from './tool-format.js'; export { formatBytes, ToolCallDetail, ToolTrow } from './tool-activity.js'; export { ToolResultPreview } from './tool-activity/tool-result-preview.js'; diff --git a/packages/ui/src/module-pages.tsx b/packages/ui/src/module-pages.tsx index d286040a81..88c6bd3cae 100644 --- a/packages/ui/src/module-pages.tsx +++ b/packages/ui/src/module-pages.tsx @@ -36,6 +36,8 @@ import type { ScheduledTaskDraftInput, ScheduledTaskUpdatePatch, SkillEntry, + SkillLocation, + SkillLocationRef, } from './module-panel-types.js'; const SkillsModuleMain = lazy(() => import('./skills-panel.js').then((module) => ({ default: module.SkillsModuleMain }))); @@ -61,12 +63,13 @@ function ModulePanelFallback(props: { message: string }) { export function SkillsPage(props: { skills?: SkillEntry[]; + skillLocations?: SkillLocation[]; hubHeader?: ModuleHubHeader; scheduledTasks?: ScheduledTask[]; onRefreshSkills?(): void | Promise; onOpenSkill?(skillId: string): void | Promise; onUseSkill?(skillId: string, skillName: string): void; - onOpenSkillsFolder?(): void | Promise; + onOpenSkillLocation?(ref: SkillLocationRef, createIfMissing: boolean): void | Promise; managedSkillSources?: ManagedSkillSourceEntry[]; onRefreshManagedSkillSources?(): void | Promise; onImportManagedSkillSource?(): void | Promise; diff --git a/packages/ui/src/module-panel-types.ts b/packages/ui/src/module-panel-types.ts index 5fed6a89e3..2672098943 100644 --- a/packages/ui/src/module-panel-types.ts +++ b/packages/ui/src/module-panel-types.ts @@ -62,6 +62,22 @@ export interface SkillEntry { manageable?: boolean; } +export type SkillLocationRef = + | 'project:maka' + | 'project:agents' + | 'workspace:legacy' + | 'user:maka' + | 'user:agents'; + +export interface SkillLocation { + ref: SkillLocationRef; + scope: 'project' | 'workspace' | 'user'; + source: 'maka' | 'agents' | 'legacy'; + path: string; + status: 'available' | 'missing' | 'blocked_path' | 'read_failed'; + skillCount: number; +} + export type SkillGovernanceStatus = 'not_managed' | 'source_missing' | 'up_to_date' | 'update_available' | 'local_modified' | 'metadata_error'; export type SkillValidationStatus = 'ok' | 'missing_lock' | 'modified' | 'metadata_error'; export type SkillValidationCode = diff --git a/packages/ui/src/skills-copy.ts b/packages/ui/src/skills-copy.ts index 8385e95e73..b8b6d151b5 100644 --- a/packages/ui/src/skills-copy.ts +++ b/packages/ui/src/skills-copy.ts @@ -131,11 +131,18 @@ export interface SkillsCopy { metaAvailable: (count: number) => string; searchMatches: (count: number) => string; search: string; - openFolder: string; + locations: string; moreActions: string; refreshing: string; refresh: string; }; + locations: { + labels: Record; + count: (count: number) => string; + missing: string; + blocked: string; + readFailed: string; + }; detail: { label: string; enabled: string; @@ -164,7 +171,8 @@ const SKILLS_COPY = { review: { ariaLabel: 'Skill 更新审查', title: '更新审查', source: (id) => `来源 ${id}`, managedSource: '受管理来源', hasBaseline: '已有基线', missingBaseline: '缺少基线', lineTransition: (current, source) => `${current} → ${source} 行`, changedLines: (count) => `${count} 行不同`, warning: '工作区副本已有本地修改。继续更新会用来源库版本覆盖当前 SKILL.md。', workspace: '当前工作区', sourceVersion: '来源库版本', cancel: '取消', overwrite: '覆盖本地修改', update: '更新到来源版本' }, bundledDescription: { 'computer-use': '查看并操作本机桌面应用的界面。' }, status: { metadataError: '元数据异常', managed: { source_missing: '来源缺失', update_available: '可更新', local_modified: '本地已修改', metadata_error: '元数据异常', up_to_date: '受管理', not_managed: '受管理' }, modified: '已修改', bundled: '内置', local: '本地', stateError: '状态异常', enabled: '已启用', disabled: '已停用' }, - page: { title: '技能', toolbarAria: '技能筛选与视图', metaInstalled: (count) => `${count} 个已安装`, metaUpdates: (count) => `${count} 个可更新`, metaAvailable: (count) => `${count} 个可安装`, searchMatches: (count) => `${count} 个匹配`, search: '搜索技能', openFolder: '打开目录', moreActions: '更多技能操作', refreshing: '刷新中…', refresh: '刷新' }, + page: { title: '技能', toolbarAria: '技能筛选与视图', metaInstalled: (count) => `${count} 个已安装`, metaUpdates: (count) => `${count} 个可更新`, metaAvailable: (count) => `${count} 个可安装`, searchMatches: (count) => `${count} 个匹配`, search: '搜索技能', locations: '技能位置…', moreActions: '更多技能操作', refreshing: '刷新中…', refresh: '刷新' }, + locations: { labels: { 'project:maka': '项目 · Maka', 'project:agents': '项目 · Agents', 'workspace:legacy': '工作区兼容目录', 'user:maka': '用户 · Maka', 'user:agents': '用户 · Agents' }, count: (count) => `${count} 个 Skill`, missing: '创建并打开', blocked: '路径已被阻止', readFailed: '无法读取' }, detail: { label: '技能详情', enabled: '启用', pinned: '已固定', inspectorOpened: (name) => `已打开 ${name} 的详情`, idLabel: '标识', scopeLabel: '范围', sourceLabel: '来源', contextLabel: '上下文', runtimeLabel: '运行状态', toolsLabel: '声明工具', pathLabel: '路径' }, }, 'zh-TW': { @@ -179,7 +187,8 @@ const SKILLS_COPY = { review: { ariaLabel: 'Skill 更新審查', title: '更新審查', source: (id) => `來源 ${id}`, managedSource: '受管理來源', hasBaseline: '已有基線', missingBaseline: '缺少基線', lineTransition: (current, source) => `${current} → ${source} 行`, changedLines: (count) => `${count} 行不同`, warning: '工作區副本已有本地修改。繼續更新會用來源庫版本覆蓋目前 SKILL.md。', workspace: '目前工作區', sourceVersion: '來源庫版本', cancel: '取消', overwrite: '覆蓋本地修改', update: '更新到來源版本' }, bundledDescription: { 'computer-use': '檢視並操作本機桌面應用的介面。' }, status: { metadataError: '後設資料異常', managed: { source_missing: '來源缺失', update_available: '可更新', local_modified: '本地已修改', metadata_error: '後設資料異常', up_to_date: '受管理', not_managed: '受管理' }, modified: '已修改', bundled: '內建', local: '本地', stateError: '狀態異常', enabled: '已啟用', disabled: '已停用' }, - page: { title: '技能', toolbarAria: '技能篩選與檢視', metaInstalled: (count) => `${count} 個已安裝`, metaUpdates: (count) => `${count} 個可更新`, metaAvailable: (count) => `${count} 個可安裝`, searchMatches: (count) => `${count} 個符合`, search: '搜尋技能', openFolder: '開啟目錄', moreActions: '更多技能操作', refreshing: '重新整理中…', refresh: '重新整理' }, + page: { title: '技能', toolbarAria: '技能篩選與檢視', metaInstalled: (count) => `${count} 個已安裝`, metaUpdates: (count) => `${count} 個可更新`, metaAvailable: (count) => `${count} 個可安裝`, searchMatches: (count) => `${count} 個符合`, search: '搜尋技能', locations: '技能位置…', moreActions: '更多技能操作', refreshing: '重新整理中…', refresh: '重新整理' }, + locations: { labels: { 'project:maka': '專案 · Maka', 'project:agents': '專案 · Agents', 'workspace:legacy': '工作區相容目錄', 'user:maka': '使用者 · Maka', 'user:agents': '使用者 · Agents' }, count: (count) => `${count} 個 Skill`, missing: '建立並開啟', blocked: '路徑已被阻止', readFailed: '無法讀取' }, detail: { label: '技能詳情', enabled: '啟用', pinned: '已固定', inspectorOpened: (name) => `已開啟 ${name} 的詳情`, idLabel: '標識', scopeLabel: '範圍', sourceLabel: '來源', contextLabel: '上下文', runtimeLabel: '執行狀態', toolsLabel: '宣告工具', pathLabel: '路徑' }, }, en: { @@ -194,7 +203,8 @@ const SKILLS_COPY = { review: { ariaLabel: 'Skill update review', title: 'Update review', source: (id) => `Source ${id}`, managedSource: 'Managed source', hasBaseline: 'Baseline available', missingBaseline: 'No baseline', lineTransition: (current, source) => `${current} → ${source} lines`, changedLines: (count) => `${count} ${count === 1 ? 'line differs' : 'lines differ'}`, warning: 'The workspace copy has local changes. Continuing will replace the current SKILL.md with the source version.', workspace: 'Current workspace', sourceVersion: 'Source version', cancel: 'Cancel', overwrite: 'Overwrite local changes', update: 'Update to source version' }, bundledDescription: { 'computer-use': 'Inspect and operate local desktop app interfaces.' }, status: { metadataError: 'Metadata error', managed: { source_missing: 'Source missing', update_available: 'Update available', local_modified: 'Locally modified', metadata_error: 'Metadata error', up_to_date: 'Managed', not_managed: 'Managed' }, modified: 'Modified', bundled: 'Built in', local: 'Local', stateError: 'State error', enabled: 'Enabled', disabled: 'Disabled' }, - page: { title: 'Skills', toolbarAria: 'Skill filters and views', metaInstalled: (count) => `${count} installed`, metaUpdates: (count) => count === 1 ? '1 update available' : `${count} updates available`, metaAvailable: (count) => count === 1 ? '1 available to install' : `${count} available to install`, searchMatches: (count) => `${count} ${count === 1 ? 'match' : 'matches'}`, search: 'Search skills', openFolder: 'Open folder', moreActions: 'More Skill actions', refreshing: 'Refreshing…', refresh: 'Refresh' }, + page: { title: 'Skills', toolbarAria: 'Skill filters and views', metaInstalled: (count) => `${count} installed`, metaUpdates: (count) => count === 1 ? '1 update available' : `${count} updates available`, metaAvailable: (count) => count === 1 ? '1 available to install' : `${count} available to install`, searchMatches: (count) => `${count} ${count === 1 ? 'match' : 'matches'}`, search: 'Search skills', locations: 'Skill locations…', moreActions: 'More Skill actions', refreshing: 'Refreshing…', refresh: 'Refresh' }, + locations: { labels: { 'project:maka': 'Project · Maka', 'project:agents': 'Project · Agents', 'workspace:legacy': 'Workspace compatibility folder', 'user:maka': 'User · Maka', 'user:agents': 'User · Agents' }, count: (count) => count === 1 ? '1 Skill' : `${count} Skills`, missing: 'Create and open', blocked: 'Path blocked', readFailed: 'Could not read' }, detail: { label: 'Skill details', enabled: 'Enabled', pinned: 'Pinned', inspectorOpened: (name) => `${name} details opened`, idLabel: 'ID', scopeLabel: 'Scope', sourceLabel: 'Source', contextLabel: 'Context', runtimeLabel: 'Runtime', toolsLabel: 'Declared tools', pathLabel: 'Path' }, }, } satisfies UiCatalog; diff --git a/packages/ui/src/skills-panel.tsx b/packages/ui/src/skills-panel.tsx index d59ec59dfb..4be3ecc5fc 100644 --- a/packages/ui/src/skills-panel.tsx +++ b/packages/ui/src/skills-panel.tsx @@ -68,6 +68,7 @@ import type { SelectorOptionData } from '@astryxdesign/core/Selector'; import { DropdownMenu, DropdownMenuItem, + DropdownMenuSubMenu, } from '@astryxdesign/core/DropdownMenu'; import { ModulePage } from './primitives/module-page.js'; import { CapabilityAuditStrip, capabilityAuditIssues } from './capability-audit-strip.js'; @@ -80,7 +81,7 @@ import { skillStatusDotVariant, } from './skill-status.js'; import type { ModuleHubHeader } from './module-hub-selector.js'; -import type { BundledSkillCatalogEntry, ManagedSkillCategory, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry } from './module-panel-types.js'; +import type { BundledSkillCatalogEntry, ManagedSkillCategory, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillLocation, SkillLocationRef } from './module-panel-types.js'; import { getSkillsCopy } from './skills-copy.js'; import { useUiLocale } from './locale-context.js'; import { useToast } from './toast.js'; @@ -95,10 +96,11 @@ export function SkillsModuleMain(props: { managedSkillSources?: ManagedSkillSourceEntry[]; bundledSkillCatalog?: BundledSkillCatalogEntry[]; auditReport?: CapabilityAuditReport; + skillLocations?: SkillLocation[]; onRefreshSkills?(): void | Promise; onOpenSkill?(skillId: string): void | Promise; onUseSkill?(skillId: string, skillName: string): void; - onOpenSkillsFolder?(): void | Promise; + onOpenSkillLocation?(ref: SkillLocationRef, createIfMissing: boolean): void | Promise; onRefreshManagedSkillSources?(): void | Promise; onRefreshBundledSkillCatalog?(): void | Promise; onImportManagedSkillSource?(): void | Promise; @@ -115,6 +117,7 @@ export function SkillsModuleMain(props: { const toast = useToast(); const mountedRef = useMountedRef(); const skills = props.skills ?? []; + const skillLocations = props.skillLocations ?? []; // Designer audit P1-5: land on skills the user can actually run, not the // marketplace — every market card is still 即将上线, and leading with @@ -642,7 +645,7 @@ export function SkillsModuleMain(props: { /> ) : undefined} actions={ - props.onOpenSkillsFolder || props.onImportManagedSkillSource || canRefreshSkillData ? ( + props.onOpenSkillLocation || props.onImportManagedSkillSource || canRefreshSkillData ? ( - {props.onOpenSkillsFolder ? ( - 0 ? ( + ) : null} {props.onImportManagedSkillSource ? ( Date: Mon, 14 Sep 2026 07:07:28 +0800 Subject: [PATCH 2/7] fix(desktop): bind Skill locations to their project context Reject stale project directory actions, preserve unreadable location diagnostics, and keep Storybook location counts consistent with inventory. Regenerate renderer architecture and Astryx inventories to satisfy CI gates. Generated-by: Codex --- apps/desktop/renderer-architecture.json | 8 +- .../module-hub-services-adapter.test.ts | 4 +- .../module-hub-skills-controller.test.ts | 174 ++++++++++++++++-- .../runtime-host-skills-ipc-main.test.ts | 83 ++++++++- .../main/__tests__/skill-locations.test.ts | 43 ++++- .../src/main/runtime-host-skills-ipc-main.ts | 31 +++- apps/desktop/src/main/skill-locations.ts | 26 ++- apps/desktop/src/preload/bridge-contract.d.ts | 10 +- apps/desktop/src/preload/preload.ts | 2 +- .../controller/use-skills-controller.ts | 46 ++++- .../src/renderer/features/module-hub/ports.ts | 22 +-- .../renderer/features/module-hub/testing.ts | 2 +- .../src/renderer/locales/shell-copy.ts | 8 +- .../src/shared/skill-location-counts.ts | 37 ++++ apps/desktop/src/shared/skill-locations.d.ts | 45 +++++ apps/desktop/stories/module-hubs.stories.tsx | 72 ++++++-- docs/astryx-surface-file-inventory.md | 2 +- 17 files changed, 512 insertions(+), 103 deletions(-) create mode 100644 apps/desktop/src/shared/skill-location-counts.ts create mode 100644 apps/desktop/src/shared/skill-locations.d.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index d15396c9eb..a6fc31ef77 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -374,7 +374,7 @@ "react": 1 }, "importSpecifiers": 9, - "nonTriviaTokens": 2299 + "nonTriviaTokens": 2276 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -524,7 +524,7 @@ "src/renderer/app-shell-project-actions.ts": { "importDeclarations": 4, "bridgePaths": { - "window.maka.app.openPath": 4, + "window.maka.app.openPath": 3, "window.maka.app.resolveProjectGitInfo": 1, "window.maka.projects.add": 1, "window.maka.projects.archive": 1, @@ -549,7 +549,7 @@ "./session-workspace-errors": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2284 + "nonTriviaTokens": 2157 }, "src/renderer/app-shell-revision-actions.ts": { "importDeclarations": 4, @@ -854,7 +854,7 @@ "react": 1 }, "importSpecifiers": 104, - "nonTriviaTokens": 13386 + "nonTriviaTokens": 13378 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 2, diff --git a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts index 585e8e07b2..56994a1566 100644 --- a/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts @@ -84,7 +84,7 @@ describe('createDesktopModuleHubServices', () => { await services.skills.setPinned('user:skill', false, host); await services.skills.delete('user:skill', host); await services.skills.open('skill', 'directory', host); - await services.skills.openLocation('user:agents', { createIfMissing: true }, host); + await services.skills.openLocation('user:agents', { contextId: 'project-context', createIfMissing: true }, host); const createInput = { title: 'Task' } as Parameters< typeof services.scheduledTasks.create @@ -125,7 +125,7 @@ describe('createDesktopModuleHubServices', () => { { name: 'skills.setPinned', args: ['user:skill', false, host] }, { name: 'skills.delete', args: ['user:skill', host] }, { name: 'skills.open', args: ['skill', 'directory', host] }, - { name: 'skills.locations.open', args: ['user:agents', { createIfMissing: true }, host] }, + { name: 'skills.locations.open', args: ['user:agents', { contextId: 'project-context', createIfMissing: true }, host] }, { name: 'scheduledTasks.list', args: [host] }, { name: 'scheduledTasks.create', args: [createInput, host] }, { name: 'scheduledTasks.update', args: ['task', updateInput, host] }, diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts index a1d179ee31..af25053614 100644 --- a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -22,6 +22,7 @@ import assert from "node:assert/strict"; import { afterEach, test } from "node:test"; import { act, createElement } from "react"; import type { SkillEntry, ToastApi } from "@maka/ui"; +import type { SkillLocationsSnapshot } from "../../shared/skill-locations.js"; import { cleanupFakeDom, installReactRenderer } from "./fake-dom.js"; import { createFakeModuleHubServices, @@ -42,6 +43,20 @@ function skill(id: string): SkillEntry { }; } +function skillLocations(project: string): SkillLocationsSnapshot { + return { + contextId: project, + locations: [{ + ref: "project:agents", + scope: "project", + source: "agents", + path: `/${project}/.agents/skills`, + status: "missing", + skillCount: 0, + }], + }; +} + type ToastRecord = { kind: "success" | "error"; title: string; @@ -148,16 +163,7 @@ test("Skills projections have independent same-Host generation and default-Host sourceType: "local", }, ], - listLocations: async () => [ - { - ref: "project:agents", - scope: "project", - source: "agents", - path: "/project/.agents/skills", - status: "available", - skillCount: 1, - }, - ], + listLocations: async () => skillLocations("project"), listBundledCatalog: async () => [ { id: "bundled-a", @@ -172,7 +178,8 @@ test("Skills projections have independent same-Host generation and default-Host }); await act(async () => renderController(root, services, input(records))); - const first = controller().host.onRefreshSkills(); + let first: Promise; + await act(async () => { first = controller().host.onRefreshSkills(); }); await act(async () => controller().host.onRefreshSkills()); assert.deepEqual( controller().host.skills.map(({ id }) => id), @@ -198,7 +205,8 @@ test("Skills projections have independent same-Host generation and default-Host const lateHostRead = deferred(); services.skills.list = async () => lateHostRead.promise; - const pending = controller().host.onRefreshSkills(); + let pending: Promise; + await act(async () => { pending = controller().host.onRefreshSkills(); }); defaultHost = hostB; await act(async () => { lateHostRead.resolve([skill("late-host-a")]); @@ -227,7 +235,7 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca }, listLocations: async () => { calls.push("locations"); - return []; + return { contextId: null, locations: [] }; }, listManagedSources: async () => { calls.push("sources"); @@ -335,6 +343,7 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = skills: { ...defaults.skills, open: async () => openFailure.promise, + listLocations: async () => skillLocations("project"), openLocation: async (ref, options) => { opened.push({ ref, createIfMissing: options.createIfMissing === true }); return { ok: true }; @@ -372,11 +381,13 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = typeof controller().host.onImportManagedSkillSource, "function", ); + assert.equal(controller().host.onOpenSkillLocation, undefined); + await act(async () => controller().host.onRefreshSkills()); assert.equal(typeof controller().host.onOpenSkillLocation, "function"); await act(async () => - controller().host.onOpenSkillLocation?.("user:agents", true), + controller().host.onOpenSkillLocation?.("project:agents", true), ); - assert.deepEqual(opened, [{ ref: "user:agents", createIfMissing: true }]); + assert.deepEqual(opened, [{ ref: "project:agents", createIfMissing: true }]); const pendingOpen = controller().host.onOpenSkill?.("skill-a"); defaultHost = hostB; @@ -444,7 +455,7 @@ test("stale Skills refresh errors do not outlive a newer successful generation", const records: ToastRecord[] = []; const host = { profileId: "profile-a", hostId: "host-a" }; const staleHostRecheck = deferred(); - const staleLocationRead = deferred<[]>(); + const staleLocationRead = deferred(); let hostReads = 0; let skillReads = 0; let locationReads = 0; @@ -466,14 +477,17 @@ test("stale Skills refresh errors do not outlive a newer successful generation", }, listLocations: async () => { locationReads += 1; - return locationReads === 1 ? staleLocationRead.promise : []; + return locationReads === 1 + ? staleLocationRead.promise + : { contextId: null, locations: [] }; }, }, }); await act(async () => renderController(root, services, input(records))); - const staleRefresh = controller().host.onRefreshSkills(); + let staleRefresh: Promise; await act(async () => { + staleRefresh = controller().host.onRefreshSkills(); await Promise.resolve(); await Promise.resolve(); }); @@ -486,7 +500,129 @@ test("stale Skills refresh errors do not outlive a newer successful generation", ); staleHostRecheck.resolve(host); - staleLocationRead.resolve([]); + staleLocationRead.resolve({ contextId: null, locations: [] }); await act(async () => staleRefresh); assert.deepEqual(records, []); }); + +test("changing Projects invalidates displayed Skill locations even when the refresh fails", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const opened: Array<{ ref: string; contextId: string; createIfMissing?: boolean }> = []; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + skills: { + ...defaults.skills, + listLocations: async () => skillLocations("project-a"), + openLocation: async (ref, options) => { + opened.push({ ref, ...options }); + return { ok: true }; + }, + }, + }); + await act(async () => + renderController(root, services, input(records, { clientPathsAccessible: true })), + ); + await act(async () => controller().refreshProjectSkills()); + assert.equal(controller().host.skillLocations[0]?.path, "/project-a/.agents/skills"); + const openPreviousLocation = controller().host.onOpenSkillLocation; + assert.ok(openPreviousLocation); + + const projectBLocations = deferred(); + services.skills.listLocations = async () => projectBLocations.promise; + let refreshing: Promise; + await act(async () => { refreshing = controller().refreshProjectSkills(); }); + assert.deepEqual(controller().host.skillLocations, []); + assert.equal(controller().host.onOpenSkillLocation, undefined); + await act(async () => openPreviousLocation("project:agents", true)); + assert.deepEqual(opened, []); + + await act(async () => { + projectBLocations.reject(new Error("project-b unavailable")); + await refreshing; + }); + assert.deepEqual(controller().host.skillLocations, []); + assert.equal(controller().host.onOpenSkillLocation, undefined); + assert.equal(records.length, 1); + assert.equal(records[0]?.description, "Skill locations could not be refreshed. Try again later."); + + services.skills.listLocations = async () => skillLocations("project-b"); + await act(async () => controller().host.onRefreshSkills()); + assert.equal(controller().host.skillLocations[0]?.path, "/project-b/.agents/skills"); + await act(async () => openPreviousLocation("project:agents", true)); + assert.deepEqual(opened, []); + await act(async () => controller().host.onOpenSkillLocation?.("project:agents", true)); + assert.deepEqual(opened, [{ + ref: "project:agents", + contextId: "project-b", + createIfMissing: true, + }]); +}); + +test("Skill location actions do not retarget a snapshot from a previous default Host", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const hostA = { profileId: "profile-a", hostId: "host-a" }; + const hostB = { profileId: "profile-b", hostId: "host-b" }; + let defaultHost = hostA; + const opened: unknown[] = []; + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => defaultHost, + }, + skills: { + ...defaults.skills, + listLocations: async () => skillLocations("project"), + openLocation: async (ref, options, host) => { + opened.push({ ref, ...options, host }); + return { ok: true }; + }, + }, + }); + await act(async () => + renderController(root, services, input(records, { clientPathsAccessible: true })), + ); + await act(async () => controller().refreshProjectSkills()); + const openPreviousLocation = controller().host.onOpenSkillLocation; + assert.ok(openPreviousLocation); + defaultHost = hostB; + await act(async () => openPreviousLocation("project:agents", true)); + assert.deepEqual(opened, []); + await act(async () => controller().refreshProjectSkills()); + await act(async () => controller().host.onOpenSkillLocation?.("project:agents", false)); + assert.deepEqual(opened, [{ + ref: "project:agents", + contextId: "project", + createIfMissing: false, + host: hostB, + }]); + assert.deepEqual(records, []); +}); + +test("a late Skill location response cannot restore the previous Project's directories", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const projectA = deferred(); + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + skills: { + ...defaults.skills, + listLocations: async () => projectA.promise, + }, + }); + await act(async () => + renderController(root, services, input(records, { clientPathsAccessible: true })), + ); + let previousRefresh: Promise; + await act(async () => { previousRefresh = controller().refreshProjectSkills(); }); + services.skills.listLocations = async () => skillLocations("project-b"); + await act(async () => controller().refreshProjectSkills()); + await act(async () => { + projectA.resolve(skillLocations("project-a")); + await previousRefresh; + }); + assert.equal(controller().host.skillLocations[0]?.path, "/project-b/.agents/skills"); + assert.deepEqual(records, []); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts index ac2c96d22a..acafd4a9aa 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts @@ -18,7 +18,12 @@ */ import assert from "node:assert/strict"; +import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { test } from "node:test"; +import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; +import type { SkillLocationsSnapshot } from "../../shared/skill-locations.js"; import type { IpcHandler } from "../ipc-reconnect-policy.js"; import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; import { registerRuntimeHostSkillsIpc } from "../runtime-host-skills-ipc-main.js"; @@ -57,12 +62,15 @@ test("projects an empty Skill surface until a remote Project is selected", async "skills:listInvocable", "skills:catalog:list", "skills:sources:list", - "skills:locations:list", ]) { const handler = handlers.get(channel); assert.ok(handler, `missing ${channel} handler`); assert.deepEqual(await handler({} as never), []); } + assert.deepEqual(await handlers.get("skills:locations:list")?.({} as never), { + contextId: null, + locations: [], + }); }); test("binds new-session Skill discovery to its explicit Project", async () => { @@ -143,3 +151,76 @@ test("blocks Skill location opening for a remote Runtime Host", async () => { ); assert.equal(opened, false); }); + +test("rejects a Skill location from the previously selected Project before creating a directory", async () => { + const root = await mkdtemp(join(tmpdir(), "maka-skill-location-context-")); + const projectA = join(root, "project-a"); + const projectB = join(root, "project-b"); + const workspaceRoot = join(root, "workspace"); + await Promise.all([projectA, projectB, workspaceRoot].map((path) => mkdir(path))); + const handlers = new Map(); + const opened: string[] = []; + let selectedProject = "project-a"; + registerRuntimeHostSkillsIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + loadSkillCatalog: async ({ workspace }: { workspace: WorkspaceTarget }) => ({ + workspace: { + target: workspace, + hostCwd: workspace.kind === "project" && workspace.projectId === "project-a" + ? projectA + : projectB, + }, + items: [], + }), + } as unknown as DesktopRuntimeHostClient, + workspaceRoot, + mainWindowController: {} as never, + getSelectedWorkspaceTarget: async () => ({ kind: "project", projectId: selectedProject }), + resolveNewSessionWorkspaceTarget: async () => undefined, + getDefaultPermissionMode: async () => "ask", + resolveLocale: async () => "en", + openPath: async (path) => { + opened.push(path); + return ""; + }, + }); + try { + const list = handlers.get("skills:locations:list"); + const open = handlers.get("skills:locations:open"); + assert.ok(list); + assert.ok(open); + const previous: SkillLocationsSnapshot = await list({} as never); + selectedProject = "project-b"; + + assert.deepEqual( + await open({} as never, "project:maka", { + contextId: previous.contextId, + createIfMissing: true, + }), + { ok: false, reason: "stale_context" }, + ); + assert.deepEqual(opened, []); + const current: SkillLocationsSnapshot = await list({} as never); + assert.equal(current.locations.find(({ ref }) => ref === "project:maka")?.status, "missing"); + for (const options of [undefined, { createIfMissing: true }, { contextId: "invalid", createIfMissing: true }]) { + assert.deepEqual(await open({} as never, "project:maka", options), { + ok: false, + reason: "stale_context", + }); + } + assert.deepEqual( + await open({} as never, "project:maka", { + contextId: current.contextId, + createIfMissing: true, + }), + { ok: true }, + ); + assert.deepEqual(opened, [await realpath(join(projectB, ".maka", "skills"))]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/__tests__/skill-locations.test.ts b/apps/desktop/src/main/__tests__/skill-locations.test.ts index 018281a67a..7accc8b82e 100644 --- a/apps/desktop/src/main/__tests__/skill-locations.test.ts +++ b/apps/desktop/src/main/__tests__/skill-locations.test.ts @@ -18,10 +18,11 @@ */ import assert from 'node:assert/strict'; -import { lstat, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { chmod, lstat, mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import test from 'node:test'; +import { writeSkillRuntimeState } from '@maka/runtime/skills'; import { listSkillLocations, resolveSkillLocation } from '../skill-locations.js'; test('lists every standard Skill location with availability and inventory counts', async () => { @@ -65,6 +66,46 @@ test('creates and resolves only an allowlisted missing Skill location', async () }); }); +test('location counts include disabled, shadowed and rejected Skill copies', async () => { + await withFixture(async (context) => { + const projectDirectory = join(context.projectRoot, '.agents', 'skills'); + const userDirectory = join(context.homeDirectory, '.agents', 'skills'); + await writeSkill(projectDirectory, 'shared-tool'); + await writeSkill(userDirectory, 'shared-tool'); + await writeSkill(userDirectory, 'disabled-tool'); + await mkdir(join(userDirectory, 'invalid-tool')); + await writeFile(join(userDirectory, 'invalid-tool', 'SKILL.md'), '# Missing metadata\n'); + assert.deepEqual(await writeSkillRuntimeState(context.workspaceRoot, new Map([ + ['user:agents:disabled-tool', false], + ])), { ok: true }); + + const locations = await listSkillLocations(context); + assert.equal(locations.find(({ ref }) => ref === 'project:agents')?.skillCount, 1); + assert.equal(locations.find(({ ref }) => ref === 'user:agents')?.skillCount, 3); + }); +}); + +test('reports an unreadable Skill directory instead of an available empty location', { + skip: process.platform === 'win32' || process.getuid?.() === 0, +}, async () => { + await withFixture(async (context) => { + const directory = join(context.projectRoot, '.agents', 'skills'); + await mkdir(directory, { recursive: true }); + await chmod(directory, 0o111); + try { + const location = (await listSkillLocations(context)) + .find(({ ref }) => ref === 'project:agents'); + assert.equal(location?.status, 'read_failed'); + assert.deepEqual(await resolveSkillLocation(context, 'project:agents', false), { + ok: false, + reason: 'read_failed', + }); + } finally { + await chmod(directory, 0o700); + } + }); +}); + test('refuses to create through a symlinked Skill location ancestor', async () => { await withFixture(async ({ projectRoot, workspaceRoot, homeDirectory, root }) => { const outside = join(root, 'outside'); diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 629df314d6..9db08cfcca 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -17,6 +17,7 @@ * under the License. */ +import { createHash } from 'node:crypto'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import { resolveSkillDiscoveryPaths, scanSkillsWithDiagnostics } from '@maka/runtime/skills'; import { type InvocableSkillEntry } from '@maka/runtime/skill-invocation'; @@ -36,8 +37,8 @@ import type { ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails, - SkillLocation, } from "@maka/ui"; +import type { OpenSkillLocationResult, SkillLocationsSnapshot } from '../shared/skill-locations.js'; import type { createMainWindowController } from "./main-window.js"; import { importManagedSkillSource, @@ -183,25 +184,31 @@ export function registerRuntimeHostSkillsIpc( ); }); - handleReconnectableRead(deps.ipcMain, "skills:locations:list", async (): Promise => { - if (deps.allowLocalPaths === false) return []; + handleReconnectableRead(deps.ipcMain, "skills:locations:list", async (): Promise => { + if (deps.allowLocalPaths === false) return { contextId: null, locations: [] }; const workspace = await deps.getSelectedWorkspaceTarget(); - if (!workspace) return []; + if (!workspace) return { contextId: null, locations: [] }; const snapshot = await deps.client.loadSkillCatalog({ workspace }, "governance"); - return listSkillLocations({ - projectRoot: snapshot.workspace.hostCwd, - workspaceRoot: deps.workspaceRoot, - }); + return { + contextId: skillLocationContextId(snapshot.workspace, deps.workspaceRoot), + locations: await listSkillLocations({ + projectRoot: snapshot.workspace.hostCwd, + workspaceRoot: deps.workspaceRoot, + }), + }; }); deps.ipcMain.handle( "skills:locations:open", - async (_event, ref: string, options?: { createIfMissing?: unknown }) => { + async (_event, ref: string, options?: { contextId?: unknown; createIfMissing?: unknown }): Promise => { if (deps.allowLocalPaths === false) { return { ok: false as const, reason: "blocked_path" as const }; } const workspace = await requireSelectedWorkspaceTarget(deps); const snapshot = await deps.client.loadSkillCatalog({ workspace }, "governance"); + if (options?.contextId !== skillLocationContextId(snapshot.workspace, deps.workspaceRoot)) { + return { ok: false, reason: "stale_context" }; + } const resolved = await resolveSkillLocation( { projectRoot: snapshot.workspace.hostCwd, @@ -385,6 +392,12 @@ export function registerRuntimeHostSkillsIpc( ); } +function skillLocationContextId(workspace: WorkspaceProjection, workspaceRoot: string): string { + return createHash('sha256') + .update(JSON.stringify([workspace.target, workspace.hostCwd, workspaceRoot])) + .digest('hex'); +} + async function loadGovernance( deps: RuntimeHostSkillsIpcDeps, workspace: WorkspaceTarget, diff --git a/apps/desktop/src/main/skill-locations.ts b/apps/desktop/src/main/skill-locations.ts index 7645440368..2d3a657245 100644 --- a/apps/desktop/src/main/skill-locations.ts +++ b/apps/desktop/src/main/skill-locations.ts @@ -17,7 +17,7 @@ * under the License. */ -import { lstat, mkdir, realpath } from 'node:fs/promises'; +import { lstat, mkdir, opendir, realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; import { isPathInside, realpathAllowMissing } from '@maka/runtime/path-containment'; @@ -27,12 +27,13 @@ import { type SkillDiscoveryEntry, } from '@maka/runtime/skills'; import type { SkillLocation, SkillLocationRef } from '@maka/ui'; +import { withSkillLocationCounts } from '../shared/skill-location-counts.js'; export type ResolveSkillLocationResult = | { readonly ok: true; readonly path: string } | { readonly ok: false; - readonly reason: 'unknown_location' | 'missing' | 'blocked_path' | 'create_failed'; + readonly reason: 'unknown_location' | 'missing' | 'blocked_path' | 'read_failed' | 'create_failed'; }; export interface SkillLocationContext { @@ -50,23 +51,19 @@ export async function listSkillLocations( context.homeDirectory ?? homedir(), ); const scan = await scanSkillsWithDiagnostics(discovery); - const counts = new Map(); - for (const skill of [...scan.inventory, ...scan.rejected]) { - const ref = skillLocationRef(skill); - counts.set(ref, (counts.get(ref) ?? 0) + 1); - } - return Promise.all(discovery.entries.map(async (entry) => { + const locations = await Promise.all(discovery.entries.map(async (entry) => { const ref = skillLocationRef(entry); const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); + const diagnostic = scan.discoveryDiagnostics.find(({ path }) => path === entry.dir); return { ref, scope: entry.scope as SkillLocation['scope'], source: entry.source as SkillLocation['source'], path: entry.dir, - status: inspected.status, - skillCount: counts.get(ref) ?? 0, + status: diagnostic?.reason ?? inspected.status, }; })); + return withSkillLocationCounts(locations, [...scan.inventory, ...scan.rejected]); } export async function resolveSkillLocation( @@ -85,7 +82,7 @@ export async function resolveSkillLocation( const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); if (inspected.status === 'available') return { ok: true, path: inspected.path }; if (inspected.status === 'blocked_path') return { ok: false, reason: 'blocked_path' }; - if (inspected.status === 'read_failed') return { ok: false, reason: 'create_failed' }; + if (inspected.status === 'read_failed') return { ok: false, reason: 'read_failed' }; if (!createIfMissing) return { ok: false, reason: 'missing' }; try { @@ -140,9 +137,10 @@ async function inspectDirectory( } try { const targetReal = await realpath(target); - return isPathInside(rootReal, targetReal) - ? { status: 'available', path: targetReal } - : { status: 'blocked_path' }; + if (!isPathInside(rootReal, targetReal)) return { status: 'blocked_path' }; + const directory = await opendir(targetReal); + await directory.close(); + return { status: 'available', path: targetReal }; } catch { return { status: 'read_failed' }; } diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 387304af9b..6570d86271 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -251,7 +251,8 @@ import type { } from '@maka/runtime/stream-graph-read-model'; import type { BotStatus, WechatBridgeQrCodeResult } from '@maka/runtime/bots'; import type { ShellRunPtyDataEvent, ShellRunPtySnapshot } from '@maka/runtime/shell-run-contract'; -import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillLocation, SkillLocationRef } from '@maka/ui'; +import type { BundledSkillCatalogEntry, ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, SkillLocationRef } from '@maka/ui'; +import type { OpenSkillLocationOptions, OpenSkillLocationResult, SkillLocationsSnapshot } from '../shared/skill-locations.js'; import type { ConfigCategory } from '@maka/storage/config-transfer'; import type { OnboardingMilestone, OnboardingMilestoneId, OnboardingState } from '@maka/core/onboarding'; import type { @@ -1903,11 +1904,8 @@ export interface MakaBridge { >; }; locations: { - list(host?: DesktopRuntimeHostRef): Promise; - open(ref: SkillLocationRef, options?: { createIfMissing?: boolean }, host?: DesktopRuntimeHostRef): Promise< - | { ok: true } - | { ok: false; reason: 'unknown_location' | 'missing' | 'blocked_path' | 'create_failed' | 'open_failed' } - >; + list(host?: DesktopRuntimeHostRef): Promise; + open(ref: SkillLocationRef, options: OpenSkillLocationOptions, host?: DesktopRuntimeHostRef): Promise; }; installManaged(sourceId: string, host?: DesktopRuntimeHostRef): Promise< | { ok: true; skill: SkillEntry } diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 0e3bb98915..4a013223fa 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3868,7 +3868,7 @@ const makaBridge = { list(host?: DesktopRuntimeHostRef) { return invokeSelectedRuntimeHost(host, 'skills:locations:list'); }, - open(ref: import('@maka/ui').SkillLocationRef, options?: { createIfMissing?: boolean }, host?: DesktopRuntimeHostRef) { + open(ref: import('@maka/ui').SkillLocationRef, options: import('../shared/skill-locations.js').OpenSkillLocationOptions, host?: DesktopRuntimeHostRef) { return invokeSelectedRuntimeHost(host, 'skills:locations:open', ref, options); }, }, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index 8246f7565b..1928cbea6c 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -28,6 +28,7 @@ import type { SkillLocationRef, ToastApi, } from "@maka/ui"; +import type { SkillLocationsSnapshot } from "../../../../shared/skill-locations.js"; import { getShellCopy, localizedShellErrorMessage, @@ -103,13 +104,19 @@ type SkillsProjection = | "managedSkillSources" | "bundledSkillCatalog"; +type SkillLocationsProjection = SkillLocationsSnapshot & { + readonly host: ModuleHubRuntimeHostRef; + readonly generation: number; +}; + /** Owns the Skills projections, their Host fences, and every Skills mutation. */ export function useSkillsController( input: UseSkillsControllerInput, ): SkillsController { const services = useModuleHubServices(); const [skills, setSkills] = useState([]); - const [skillLocations, setSkillLocations] = useState([]); + const [skillLocationSnapshot, setSkillLocationSnapshot] = + useState(null); const [revision, setRevision] = useState(0); const [managedSkillSources, setManagedSkillSources] = useState< ManagedSkillSourceEntry[] @@ -282,6 +289,7 @@ export function useSkillsController( const refreshSkillLocations = useCallback( async (options: RefreshOptions = {}): Promise => { const generation = ++generationsRef.current.skillLocations; + setSkillLocationSnapshot(null); const copy = getShellCopy(inputRef.current.uiLocale).skillActions; try { const next = await runOnDefaultRuntimeHost( @@ -296,7 +304,11 @@ export function useSkillsController( mountedRef.current && generation === generationsRef.current.skillLocations ) { - setSkillLocations(next.value); + setSkillLocationSnapshot({ + ...next.value, + host: next.host, + generation, + }); } }, ); @@ -848,14 +860,29 @@ export function useSkillsController( ref: SkillLocationRef, createIfMissing: boolean, ): Promise => { + const contextId = skillLocationSnapshot?.contextId; + if (!contextId) return; + const isCurrent = () => + isSkillsSurfaceActive() && + inputRef.current.clientPathsAccessible && + skillLocationSnapshot.generation === generationsRef.current.skillLocations; + if (!isCurrent()) return; const copy = getShellCopy(inputRef.current.uiLocale).skillActions; try { const next = await runOnDefaultRuntimeHost( services.runtimeHosts, - (host) => services.skills.openLocation(ref, { createIfMissing }, host), + async (host) => { + if ( + !isCurrent() || + host.profileId !== skillLocationSnapshot.host.profileId || + host.hostId !== skillLocationSnapshot.host.hostId + ) return null; + return services.skills.openLocation(ref, { contextId, createIfMissing }, host); + }, ); + if (!next.value || !isCurrent()) return; if (!next.value.ok) { - if (await shouldReportMutation(next.host)) { + if ((await shouldReportMutation(next.host)) && isCurrent()) { inputRef.current.toastApi.error( copy.openLocationFailedTitle, copy.openLocationFailures[next.value.reason], @@ -869,7 +896,7 @@ export function useSkillsController( await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); } } catch (error) { - if (await shouldReportOperationError(error)) { + if ((await shouldReportOperationError(error)) && isCurrent()) { reportRuntimeHostError( copy.openLocationFailedTitle, copy.openLocationFallback, @@ -882,6 +909,7 @@ export function useSkillsController( isSkillsSurfaceActive, refreshSkillLocations, reportRuntimeHostError, + skillLocationSnapshot, services.runtimeHosts, services.skills, shouldReportOperationError, @@ -901,7 +929,7 @@ export function useSkillsController( const host = useMemo( () => ({ skills, - skillLocations, + skillLocations: skillLocationSnapshot?.locations ?? [], managedSkillSources, bundledSkillCatalog, onRefreshSkills: async () => { @@ -911,7 +939,9 @@ export function useSkillsController( ...(input.clientPathsAccessible ? { onOpenSkill: openSkill, - onOpenSkillLocation: openSkillLocation, + ...(skillLocationSnapshot?.contextId + ? { onOpenSkillLocation: openSkillLocation } + : {}), onImportManagedSkillSource: importManagedSkillSource, } : {}), @@ -940,7 +970,7 @@ export function useSkillsController( refreshBundledSkillCatalog, refreshSkillLocations, refreshManagedSkillSources, - skillLocations, + skillLocationSnapshot, refreshSkills, setSkillEnabled, setSkillPinned, diff --git a/apps/desktop/src/renderer/features/module-hub/ports.ts b/apps/desktop/src/renderer/features/module-hub/ports.ts index 53a0cafd61..f8609a2dba 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -34,9 +34,13 @@ import type { ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, - SkillLocation, SkillLocationRef, } from '@maka/ui'; +import type { + OpenSkillLocationOptions, + OpenSkillLocationResult, + SkillLocationsSnapshot, +} from '../../../shared/skill-locations.js'; export type ModuleHubUnsubscribe = () => void; @@ -131,21 +135,9 @@ export type OpenSkillResult = | 'open_failed'; }; -export type OpenSkillLocationResult = - | { ok: true } - | { - ok: false; - reason: - | 'unknown_location' - | 'missing' - | 'blocked_path' - | 'create_failed' - | 'open_failed'; - }; - export interface ModuleHubSkillsService { list(host: ModuleHubRuntimeHostRef): Promise; - listLocations(host: ModuleHubRuntimeHostRef): Promise; + listLocations(host: ModuleHubRuntimeHostRef): Promise; listManagedSources(host: ModuleHubRuntimeHostRef): Promise; listBundledCatalog(host: ModuleHubRuntimeHostRef): Promise; importManagedSource(host: ModuleHubRuntimeHostRef): Promise; @@ -182,7 +174,7 @@ export interface ModuleHubSkillsService { ): Promise; openLocation( ref: SkillLocationRef, - options: { createIfMissing?: boolean }, + options: OpenSkillLocationOptions, host: ModuleHubRuntimeHostRef, ): Promise; } diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index 21a192fb44..dc9729568c 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -138,7 +138,7 @@ export function createFakeModuleHubServices( }, skills: { list: async () => [], - listLocations: async () => [], + listLocations: async () => ({ contextId: null, locations: [] }), listManagedSources: async () => [], listBundledCatalog: async () => [], importManagedSource: async () => diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 954d8158c3..1ee4b8562a 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -329,7 +329,7 @@ type ShellCopy = { openLocationFailedTitle: string; openLocationFallback: string; openLocationFailures: Record< - 'unknown_location' | 'missing' | 'blocked_path' | 'create_failed' | 'open_failed', + 'unknown_location' | 'stale_context' | 'missing' | 'blocked_path' | 'read_failed' | 'create_failed' | 'open_failed', string >; openFailures: Record< @@ -951,8 +951,10 @@ const SHELL_COPY_BY_LOCALE = { openLocationFallback: '无法打开技能位置,请稍后重试。', openLocationFailures: { unknown_location: '这个技能位置无效。', + stale_context: '项目已切换,请刷新技能位置后重试。', missing: '目录不存在。', blocked_path: '技能位置不在允许范围内,已阻止打开。', + read_failed: '无法读取技能目录,请检查文件权限。', create_failed: '无法创建技能目录,请检查文件权限。', open_failed: '系统打开目录失败。', }, @@ -1469,8 +1471,10 @@ const SHELL_COPY_BY_LOCALE = { openLocationFallback: '無法開啟技能位置,請稍後重試。', openLocationFailures: { unknown_location: '這個技能位置無效。', + stale_context: '專案已切換,請重新整理技能位置後再試。', missing: '目錄不存在。', blocked_path: '技能位置不在允許範圍內,已阻止開啟。', + read_failed: '無法讀取技能目錄,請檢查檔案權限。', create_failed: '無法建立技能目錄,請檢查檔案權限。', open_failed: '系統無法開啟目錄。', }, @@ -1992,8 +1996,10 @@ const SHELL_COPY_BY_LOCALE = { openLocationFallback: 'The Skill location could not be opened. Try again later.', openLocationFailures: { unknown_location: 'This Skill location is invalid.', + stale_context: 'The project has changed. Refresh Skill locations and try again.', missing: 'The folder does not exist.', blocked_path: 'The Skill location is outside the allowed paths, so opening was blocked.', + read_failed: 'The Skill folder could not be read. Check file permissions.', create_failed: 'The Skill folder could not be created. Check file permissions.', open_failed: 'The system could not open the folder.', }, diff --git a/apps/desktop/src/shared/skill-location-counts.ts b/apps/desktop/src/shared/skill-location-counts.ts new file mode 100644 index 0000000000..1d408536f7 --- /dev/null +++ b/apps/desktop/src/shared/skill-location-counts.ts @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SkillEntry, SkillLocation } from '@maka/ui'; + +/** Counts every discovered copy, including disabled, shadowed and rejected Skills. */ +export function withSkillLocationCounts( + locations: readonly Omit[], + skills: readonly Pick[], +): SkillLocation[] { + const counts = new Map(); + for (const skill of skills) { + if (skill.kind === 'discovery_diagnostic') continue; + const ref = `${skill.scope}:${skill.source}`; + counts.set(ref, (counts.get(ref) ?? 0) + 1); + } + return locations.map((location) => ({ + ...location, + skillCount: counts.get(location.ref) ?? 0, + })); +} diff --git a/apps/desktop/src/shared/skill-locations.d.ts b/apps/desktop/src/shared/skill-locations.d.ts new file mode 100644 index 0000000000..601960eca4 --- /dev/null +++ b/apps/desktop/src/shared/skill-locations.d.ts @@ -0,0 +1,45 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import type { SkillLocation } from '@maka/ui'; + +export interface SkillLocationsSnapshot { + /** Opaque identity of the workspace that supplied these locations. */ + readonly contextId: string | null; + readonly locations: SkillLocation[]; +} + +export interface OpenSkillLocationOptions { + readonly contextId: string; + readonly createIfMissing?: boolean; +} + +export type OpenSkillLocationResult = + | { ok: true } + | { + ok: false; + reason: + | 'unknown_location' + | 'stale_context' + | 'missing' + | 'blocked_path' + | 'read_failed' + | 'create_failed' + | 'open_failed'; + }; diff --git a/apps/desktop/stories/module-hubs.stories.tsx b/apps/desktop/stories/module-hubs.stories.tsx index 804a7476ac..1c5f680ad7 100644 --- a/apps/desktop/stories/module-hubs.stories.tsx +++ b/apps/desktop/stories/module-hubs.stories.tsx @@ -30,6 +30,7 @@ import { SkillsPage, type ManagedSkillUpdatePreview, type SkillEntry, + type SkillLocation, ToastProvider, useUiLocale, } from '@maka/ui'; @@ -49,6 +50,7 @@ import { import { AppShellDetailPanel } from '../src/renderer/app-shell-detail-panel'; import { McpPage } from '../src/renderer/mcp-page'; import { withScopedMakaBridge } from './maka-bridge'; +import { withSkillLocationCounts } from '../src/shared/skill-location-counts'; // Fidelity convention (#1433): every story below names the real app path // that reaches it. See apps/desktop/stories/FIDELITY.md. @@ -79,14 +81,15 @@ const CONFIGURED_COMPLETED_LAST_RUN = { const INSTALLED_SKILLS: SkillEntry[] = [ { - ref: 'workspace:maka:skill-git-flow', + ref: 'workspace:legacy:skill-git-flow', id: 'skill-git-flow', name: 'git-flow', description: '封装分支创建、合并与发布打 tag 的常用 git 操作。', - path: '~/.maka/skills/git-flow', + path: '/workspace/skills/skill-git-flow', declaredTools: ['Bash', 'Write'], sourceType: 'workspace', scope: 'workspace', + source: 'legacy', contextStatus: 'advertised', manageable: true, enabled: true, @@ -97,24 +100,26 @@ const INSTALLED_SKILLS: SkillEntry[] = [ id: 'skill-docs-screenshot', name: 'docs-screenshot', description: '把组件截图同步进设计文档,按 token 分类命名。', - path: '~/.maka/skills/docs-screenshot', + path: '/home/maka/.agents/skills/skill-docs-screenshot', declaredTools: ['Bash', 'Read'], sourceType: 'workspace', scope: 'user', + source: 'agents', contextStatus: 'disabled', manageable: true, enabled: false, runtimeStatus: 'disabled', }, { - ref: 'project:maka:skill-release-notes', + ref: 'project:agents:skill-release-notes', id: 'skill-release-notes', name: 'release-notes', description: '从最近的 commit 历史生成发布说明草稿。', - path: '~/.maka/skills/release-notes', + path: '/project/.agents/skills/skill-release-notes', declaredTools: ['Bash'], - sourceType: 'bundled', + sourceType: 'workspace', scope: 'project', + source: 'agents', contextStatus: 'advertised', manageable: false, enabled: true, @@ -124,15 +129,16 @@ const INSTALLED_SKILLS: SkillEntry[] = [ const UPDATE_AVAILABLE_SKILLS: SkillEntry[] = [ { - ref: 'workspace:maka:release-checklist', + ref: 'workspace:legacy:release-checklist', id: 'release-checklist', name: 'release-checklist', description: '发布前检查版本、测试证据和变更说明。', - path: '~/.maka/skills/release-checklist', + path: '/workspace/skills/release-checklist', declaredTools: ['Bash', 'Read'], sourceType: 'managed', managedUpdateStatus: 'update_available', scope: 'workspace', + source: 'legacy', contextStatus: 'advertised', manageable: true, enabled: true, @@ -145,7 +151,7 @@ const UPDATE_AVAILABLE_PREVIEW: ManagedSkillUpdatePreview = { id: 'release-checklist', name: 'release-checklist', description: '发布前检查版本、测试证据和变更说明。', - path: '~/.maka/skills/release-checklist/SKILL.md', + path: '/workspace/skills/release-checklist/SKILL.md', declaredTools: ['Bash', 'Read'], sourceType: 'managed', userModified: false, @@ -172,14 +178,15 @@ const UPDATE_AVAILABLE_PREVIEW: ManagedSkillUpdatePreview = { const DISABLED_SKILLS: SkillEntry[] = [ { - ref: 'workspace:maka:spreadsheet-audit', + ref: 'workspace:legacy:spreadsheet-audit', id: 'spreadsheet-audit', name: 'spreadsheet-audit', description: '检查工作簿中的公式、格式和异常值。', - path: '~/.maka/skills/spreadsheet-audit', + path: '/workspace/skills/spreadsheet-audit', declaredTools: ['Read'], sourceType: 'bundled', scope: 'workspace', + source: 'legacy', contextStatus: 'disabled', manageable: true, enabled: false, @@ -191,20 +198,31 @@ const DISABLED_SKILLS: SkillEntry[] = [ // viewport — the #2236 regression surface (the view switch scrolling away // with the list) only exists when the list is taller than its container. const LONG_LIST_SKILLS: SkillEntry[] = Array.from({ length: 40 }, (_, index) => ({ - ref: `workspace:maka:skill-long-${index}`, + ref: `workspace:legacy:skill-long-${index}`, id: `skill-long-${index}`, name: `long-list-skill-${index}`, description: '长列表占位技能,用于滚动契约。', - path: `~/.maka/skills/skill-long-${index}`, + path: `/workspace/skills/skill-long-${index}`, declaredTools: ['Bash'], sourceType: 'workspace', scope: 'workspace', + source: 'legacy', contextStatus: 'advertised', manageable: true, enabled: true, runtimeStatus: 'enabled', })); +// A local Project with readable cross-client and compatibility directories; +// its client-specific directories have not been created yet. +const SKILL_DIRECTORIES: Omit[] = [ + { ref: 'project:maka', scope: 'project', source: 'maka', path: '/project/.maka/skills', status: 'missing' }, + { ref: 'project:agents', scope: 'project', source: 'agents', path: '/project/.agents/skills', status: 'available' }, + { ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', path: '/workspace/skills', status: 'available' }, + { ref: 'user:maka', scope: 'user', source: 'maka', path: '/home/maka/.maka/skills', status: 'missing' }, + { ref: 'user:agents', scope: 'user', source: 'agents', path: '/home/maka/.agents/skills', status: 'available' }, +]; + const BUNDLED_SKILLS: NonNullable['bundledSkillCatalog']> = [ { id: 'document-review', @@ -698,13 +716,7 @@ function ExtensionsSkillsSurface(props: { badge: {}} />, }} skills={props.skills ?? []} - skillLocations={[ - { ref: 'project:maka', scope: 'project', source: 'maka', path: '/project/.maka/skills', status: 'missing', skillCount: 0 }, - { ref: 'project:agents', scope: 'project', source: 'agents', path: '/project/.agents/skills', status: 'available', skillCount: 2 }, - { ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', path: '/workspace/skills', status: 'missing', skillCount: 0 }, - { ref: 'user:maka', scope: 'user', source: 'maka', path: '~/.maka/skills', status: 'available', skillCount: 1 }, - { ref: 'user:agents', scope: 'user', source: 'agents', path: '~/.agents/skills', status: 'available', skillCount: 4 }, - ]} + skillLocations={withSkillLocationCounts(SKILL_DIRECTORIES, props.skills ?? [])} managedSkillSources={[]} bundledSkillCatalog={props.bundledSkillCatalog ?? []} onRefreshSkills={noop} @@ -938,6 +950,26 @@ export const ExtensionsSkillsInstalled: Story = { render: () => , }; +// Real path: sidebar → 扩展 → 技能 → 更多技能操作 → 技能位置, +// with installed copies in the project, compatibility and user directories. +export const ExtensionsSkillsLocations: Story = { + render: () => , + play: async ({ canvasElement }) => { + const more = await waitForStoryButton( + canvasElement, + (button) => button.getAttribute('aria-label') === '更多技能操作', + ); + more.click(); + const body = canvasElement.ownerDocument.body; + const submenu = await waitForStorySelector( + body, + '[role="menuitem"][aria-haspopup="menu"]', + ); + submenu.click(); + await waitForStoryText(body, '/home/maka/.agents/skills'); + }, +}; + // Real path: sidebar → 扩展 → 技能, with bundled Skills available to install. export const ExtensionsSkillsBundled: Story = { render: () => , diff --git a/docs/astryx-surface-file-inventory.md b/docs/astryx-surface-file-inventory.md index ff6a01a028..36658d716f 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -292,7 +292,7 @@ Wiki bar: Design Conventions · API Use-the-System · Theming · Container Paddi | `packages/ui/src/session-rename-dialog.tsx` | dialog-overlay | Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput | aligned — uses Astryx (Button, Dialog, DialogHeader, HStack, Layout, LayoutContent, LayoutFooter, TextInput) | aligned | | `packages/ui/src/session-sidebar-nav.tsx` | shell-chrome-or-panel | Icon, IconButton, SideNavItem, SideNavSection, Tooltip | aligned — uses Astryx (Icon, IconButton, SideNavItem, SideNavSection, Tooltip) | aligned | | `packages/ui/src/skill-inspector.tsx` | shell-chrome-or-panel | Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot, Switch, Text, VStack | aligned — uses Astryx (Button, Divider, HStack, Heading, MetadataList, MetadataListItem, StackItem, StatusDot) | aligned | -| `packages/ui/src/skills-panel.tsx` | module-hub | Button, DropdownMenu, DropdownMenuItem, EmptyState, IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Text, TextInput, Toolbar | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, EmptyState, IconButton, List, ListItem, SegmentedControl) | aligned | +| `packages/ui/src/skills-panel.tsx` | module-hub | Button, DropdownMenu, DropdownMenuItem, DropdownMenuSubMenu, EmptyState, IconButton, List, ListItem, SegmentedControl, SegmentedControlItem, Selector, StatusDot, Text, TextInput, Toolbar | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, DropdownMenuSubMenu, EmptyState, IconButton, List, ListItem) | aligned | | `packages/ui/src/styles.css` | ui-composition | n/a (css) | aligned — no off-rhythm control heights flagged | aligned | | `packages/ui/src/titlebar-session-identity.tsx` | shell-chrome-or-panel | Button, DropdownMenu, DropdownMenuItem, IconButton | aligned — uses Astryx (Button, DropdownMenu, DropdownMenuItem, IconButton) | aligned | | `packages/ui/src/toast.tsx` | ui-composition | AlertDialog, Button, HStack, LayerProvider, Text, VStack | aligned — uses Astryx (AlertDialog, Button, HStack, LayerProvider, Text, VStack) | aligned | From 465f023408763425730dab569a5d3b7605ca2713 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Mon, 14 Sep 2026 07:11:43 +0800 Subject: [PATCH 3/7] test(desktop): register POSIX Skill permissions coverage Record the platform-specific skip in the generated Windows test inventory. Generated-by: Codex --- apps/desktop/src/main/__tests__/skill-locations.test.ts | 4 +++- docs/windows-test-inventory.md | 5 +++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/__tests__/skill-locations.test.ts b/apps/desktop/src/main/__tests__/skill-locations.test.ts index 7accc8b82e..9a7014f846 100644 --- a/apps/desktop/src/main/__tests__/skill-locations.test.ts +++ b/apps/desktop/src/main/__tests__/skill-locations.test.ts @@ -86,7 +86,9 @@ test('location counts include disabled, shadowed and rejected Skill copies', asy }); test('reports an unreadable Skill directory instead of an available empty location', { - skip: process.platform === 'win32' || process.getuid?.() === 0, + skip: process.platform === 'win32' + ? 'POSIX permissions are required to make the Skill directory unreadable' + : process.getuid?.() === 0, }, async () => { await withFixture(async (context) => { const directory = join(context.projectRoot, '.agents', 'skills'); diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index acaf8efc3a..c14d1861ff 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 32 | -| platform-contract | 36 | +| platform-contract | 37 | -Total Windows-excluded declarations: **95** +Total Windows-excluded declarations: **96** ## Inventory @@ -35,6 +35,7 @@ Total Windows-excluded declarations: **95** | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` keeps the inherited PATH and does not log shell stderr when capture fails | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` bounds shell output instead of buffering until the global timeout | `process.platform === 'win32'` | +| platform-contract | `apps/desktop/src/main/__tests__/skill-locations.test.ts` reports an unreadable Skill directory instead of an available empty location | `process.platform === 'win32' ? 'POSIX permissions are required to make the Skill directory unreadable' : process.getuid?.() === 0` | | platform-contract | `packages/cli/src/__tests__/acp-prompt-content.test.ts` rejects a FIFO without blocking the process | `process.platform === 'win32'` | | portable-candidate | `packages/cli/src/__tests__/pi-transcript.test.ts` shortens POSIX paths under the home directory | `process.platform === 'win32'` | | portable-candidate | `packages/cli/src/__tests__/pi-transcript.test.ts` keeps POSIX paths outside the home directory absolute | `process.platform === 'win32'` | From 631bb70a1ebf41609eb6281842ba6575771f4177 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 18 Sep 2026 15:46:18 +0800 Subject: [PATCH 4/7] fix(desktop): isolate Skill location scopes from missing projects Resolve local Skill locations independently of the governance catalog and bind opens to Host-scoped canonical roots. Keep workspace and user locations available when the selected Project disappears, without recreating its path. Generated-by: Codex --- .../module-hub-skills-controller.test.ts | 44 +++- .../runtime-host-skills-ipc-main.test.ts | 222 +++++++++++++++++- apps/desktop/src/main/runtime-host-boot.ts | 1 + .../src/main/runtime-host-skills-ipc-main.ts | 79 +++++-- apps/desktop/src/main/skill-locations.ts | 22 +- .../controller/use-skills-controller.ts | 7 +- .../renderer/features/module-hub/testing.ts | 2 +- apps/desktop/src/shared/skill-locations.d.ts | 4 +- packages/runtime/src/skills-discovery.ts | 26 +- 9 files changed, 363 insertions(+), 44 deletions(-) diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts index af25053614..3888de8ed9 100644 --- a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -45,7 +45,7 @@ function skill(id: string): SkillEntry { function skillLocations(project: string): SkillLocationsSnapshot { return { - contextId: project, + contextIds: { project }, locations: [{ ref: "project:agents", scope: "project", @@ -235,7 +235,7 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca }, listLocations: async () => { calls.push("locations"); - return { contextId: null, locations: [] }; + return { contextIds: {}, locations: [] }; }, listManagedSources: async () => { calls.push("sources"); @@ -479,7 +479,7 @@ test("stale Skills refresh errors do not outlive a newer successful generation", locationReads += 1; return locationReads === 1 ? staleLocationRead.promise - : { contextId: null, locations: [] }; + : { contextIds: {}, locations: [] }; }, }, }); @@ -500,7 +500,7 @@ test("stale Skills refresh errors do not outlive a newer successful generation", ); staleHostRecheck.resolve(host); - staleLocationRead.resolve({ contextId: null, locations: [] }); + staleLocationRead.resolve({ contextIds: {}, locations: [] }); await act(async () => staleRefresh); assert.deepEqual(records, []); }); @@ -626,3 +626,39 @@ test("a late Skill location response cannot restore the previous Project's direc assert.equal(controller().host.skillLocations[0]?.path, "/project-b/.agents/skills"); assert.deepEqual(records, []); }); + +test("independent Skill locations stay actionable when the Project has no usable context", async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const opened: unknown[] = []; + const defaults = createFakeModuleHubServices(); + const snapshot: SkillLocationsSnapshot = { + contextIds: { workspace: 'workspace-context', user: 'user-context' }, + locations: [ + { ref: 'project:agents', scope: 'project', source: 'agents', path: '/missing/.agents/skills', status: 'read_failed', skillCount: 0 }, + { ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', path: '/workspace/skills', status: 'available', skillCount: 0 }, + { ref: 'user:agents', scope: 'user', source: 'agents', path: '/home/.agents/skills', status: 'missing', skillCount: 0 }, + ], + }; + const services = createFakeModuleHubServices({ + skills: { + ...defaults.skills, + list: async () => { throw new Error('Project is unavailable'); }, + listLocations: async () => snapshot, + openLocation: async (ref, options) => { opened.push({ ref, ...options }); return { ok: true }; }, + }, + }); + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); + await act(async () => controller().refreshProjectSkills()); + assert.deepEqual(controller().host.skillLocations, snapshot.locations); + const open = controller().host.onOpenSkillLocation; + assert.ok(open); + await act(async () => open('project:agents', true)); + assert.deepEqual(opened, []); + await act(async () => open('workspace:legacy', false)); + await act(async () => open('user:agents', true)); + assert.deepEqual(opened, [ + { ref: 'workspace:legacy', contextId: 'workspace-context', createIfMissing: false }, + { ref: 'user:agents', contextId: 'user-context', createIfMissing: true }, + ]); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts index acafd4a9aa..acc6caee8a 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts @@ -18,13 +18,18 @@ */ import assert from "node:assert/strict"; -import { mkdir, mkdtemp, realpath, rm } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, realpath, rename, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; +import { createProjectCatalog } from "@maka/storage/project-catalog"; +import { HostProjectMembershipGate } from "../../../../../packages/runtime-host/dist/server/project-membership-gate.js"; +import { HostWorkspaceResolver } from "../../../../../packages/runtime-host/dist/server/workspace-resolver.js"; import type { SkillLocationsSnapshot } from "../../shared/skill-locations.js"; import type { IpcHandler } from "../ipc-reconnect-policy.js"; +import { createProjectManagementService } from "../project-management-service.js"; +import type { CurrentProjectSelection } from "../project-root-controller.js"; import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; import { registerRuntimeHostSkillsIpc } from "../runtime-host-skills-ipc-main.js"; @@ -50,6 +55,7 @@ test("projects an empty Skill surface until a remote Project is selected", async workspaceRoot: "/client-workspace", mainWindowController: {} as never, getSelectedWorkspaceTarget: async () => undefined, + getSelectedProject: async () => { throw new Error('Remote Host has no Client Project path'); }, resolveNewSessionWorkspaceTarget: async () => undefined, getDefaultPermissionMode: async () => "ask", openPath: async () => "", @@ -68,7 +74,7 @@ test("projects an empty Skill surface until a remote Project is selected", async assert.deepEqual(await handler({} as never), []); } assert.deepEqual(await handlers.get("skills:locations:list")?.({} as never), { - contextId: null, + contextIds: {}, locations: [], }); }); @@ -92,6 +98,7 @@ test("binds new-session Skill discovery to its explicit Project", async () => { workspaceRoot: "/client-workspace", mainWindowController: {} as never, getSelectedWorkspaceTarget: async () => undefined, + getSelectedProject: async () => { throw new Error('No Project selected'); }, resolveNewSessionWorkspaceTarget: async (projectId) => { resolvedProjectIds.push(projectId); return typeof projectId === "string" @@ -133,6 +140,7 @@ test("blocks Skill location opening for a remote Runtime Host", async () => { workspaceRoot: "/client-workspace", mainWindowController: {} as never, getSelectedWorkspaceTarget: async () => ({ kind: "project", projectId: "remote" }), + getSelectedProject: async () => { throw new Error('Remote Host has no Client Project path'); }, resolveNewSessionWorkspaceTarget: async () => undefined, getDefaultPermissionMode: async () => "ask", openPath: async () => { @@ -157,7 +165,8 @@ test("rejects a Skill location from the previously selected Project before creat const projectA = join(root, "project-a"); const projectB = join(root, "project-b"); const workspaceRoot = join(root, "workspace"); - await Promise.all([projectA, projectB, workspaceRoot].map((path) => mkdir(path))); + const homeDirectory = join(root, "home"); + await Promise.all([projectA, projectB, workspaceRoot, homeDirectory].map((path) => mkdir(path))); const handlers = new Map(); const opened: string[] = []; let selectedProject = "project-a"; @@ -178,8 +187,13 @@ test("rejects a Skill location from the previously selected Project before creat }), } as unknown as DesktopRuntimeHostClient, workspaceRoot, + homeDirectory, mainWindowController: {} as never, getSelectedWorkspaceTarget: async () => ({ kind: "project", projectId: selectedProject }), + getSelectedProject: async () => ({ + projectId: selectedProject, + path: selectedProject === 'project-a' ? projectA : projectB, + }), resolveNewSessionWorkspaceTarget: async () => undefined, getDefaultPermissionMode: async () => "ask", resolveLocale: async () => "en", @@ -198,7 +212,7 @@ test("rejects a Skill location from the previously selected Project before creat assert.deepEqual( await open({} as never, "project:maka", { - contextId: previous.contextId, + contextId: previous.contextIds.project, createIfMissing: true, }), { ok: false, reason: "stale_context" }, @@ -214,7 +228,7 @@ test("rejects a Skill location from the previously selected Project before creat } assert.deepEqual( await open({} as never, "project:maka", { - contextId: current.contextId, + contextId: current.contextIds.project, createIfMissing: true, }), { ok: true }, @@ -224,3 +238,201 @@ test("rejects a Skill location from the previously selected Project before creat await rm(root, { recursive: true, force: true }); } }); + +for (const createIfMissing of [false, true]) { + test(`can ${createIfMissing ? 'create and open missing' : 'open existing'} independent Skill directories after the selected Project disappears`, async () => { + const root = await mkdtemp(join(tmpdir(), "maka-skill-location-missing-project-")); + const projectRoot = join(root, "project"); + const workspaceRoot = join(root, "workspace"); + const homeDirectory = join(root, "home"); + await Promise.all([projectRoot, workspaceRoot, homeDirectory].map((path) => mkdir(path))); + const independentLocations = [ + { ref: 'workspace:legacy', scope: 'workspace', path: join(workspaceRoot, 'skills') }, + { ref: 'user:maka', scope: 'user', path: join(homeDirectory, '.maka', 'skills') }, + { ref: 'user:agents', scope: 'user', path: join(homeDirectory, '.agents', 'skills') }, + ] as const; + if (!createIfMissing) { + await Promise.all(independentLocations.map(({ path }) => mkdir(path, { recursive: true }))); + } + const catalog = createProjectCatalog(join(root, "state")); + try { + const project = await catalog.register(projectRoot); + let selection: CurrentProjectSelection = { + projectId: project.id, + path: await realpath(projectRoot), + }; + const management = createProjectManagementService({ + catalog: { + list: () => catalog.list(), + register: (path) => catalog.register(path), + relink: async (id, path) => (await catalog.relinkWithSessions(id, path)).project, + rename: (id, name) => catalog.rename(id, name), + archive: (id) => catalog.archive(id), + restore: (id) => catalog.restore(id), + }, + chooseDirectory: async () => undefined, + selection: { + currentSelection: async () => selection, + setSelection: (projectId, path) => { selection = { projectId, path }; }, + }, + capabilities: { + chooseClientDirectory: true, + chooseHostDirectory: false, + selectNoProject: true, + setLocalDefault: true, + viewClientPath: true, + }, + }); + const resolver = new HostWorkspaceResolver(catalog, new HostProjectMembershipGate(), () => {}); + const handlers = new Map(); + const opened: string[] = []; + registerRuntimeHostSkillsIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: { + loadSkillCatalog: async ({ workspace }: { workspace: WorkspaceTarget }) => { + const resolved = await resolver.resolve(workspace); + return { workspace: { target: resolved.target, hostCwd: resolved.cwd }, items: [] }; + }, + } as unknown as DesktopRuntimeHostClient, + workspaceRoot, + homeDirectory, + mainWindowController: {} as never, + getSelectedWorkspaceTarget: async () => { + const current = await management.current(); + return typeof current.projectId === "string" + ? { kind: "project", projectId: current.projectId } + : { kind: "host_path", path: current.path }; + }, + getSelectedProject: () => management.current(), + resolveNewSessionWorkspaceTarget: async () => undefined, + getDefaultPermissionMode: async () => "ask", + resolveLocale: async () => "en", + openPath: async (path) => { + opened.push(path); + return ""; + }, + }); + const list = handlers.get("skills:locations:list"); + const open = handlers.get("skills:locations:open"); + assert.ok(list); + assert.ok(open); + const previous: SkillLocationsSnapshot = await list({} as never); + assert.ok(previous.contextIds.project); + + await rename(projectRoot, join(root, "moved-project")); + const current: SkillLocationsSnapshot = await list({} as never); + assert.equal((await management.current()).projectId, null); + assert.deepEqual(current.locations.map(({ ref, status }) => ({ ref, status })), [ + { ref: 'project:maka', status: 'read_failed' }, + { ref: 'project:agents', status: 'read_failed' }, + { ref: 'workspace:legacy', status: createIfMissing ? 'missing' : 'available' }, + { ref: 'user:maka', status: createIfMissing ? 'missing' : 'available' }, + { ref: 'user:agents', status: createIfMissing ? 'missing' : 'available' }, + ]); + for (const { ref, scope } of independentLocations) { + assert.equal(current.contextIds[scope], previous.contextIds[scope]); + assert.deepEqual(await open({} as never, ref, { + contextId: previous.contextIds[scope], + createIfMissing, + }), { ok: true }); + assert.deepEqual(await open({} as never, ref, { contextId: current.contextIds[scope] }), { ok: true }); + } + assert.deepEqual(opened, ( + await Promise.all(independentLocations.map(({ path }) => realpath(path))) + ).flatMap((path) => [path, path])); + const projectContexts: Array = [previous.contextIds.project, current.contextIds.project]; + for (const ref of ['project:maka', 'project:agents']) { + for (const contextId of projectContexts) { + assert.deepEqual(await open({} as never, ref, { contextId, createIfMissing: true }), { + ok: false, + reason: 'stale_context', + }); + } + } + await assert.rejects(lstat(projectRoot), { code: 'ENOENT' }); + await assert.rejects(lstat(join(workspaceRoot, '.maka')), { code: 'ENOENT' }); + await assert.rejects(lstat(join(workspaceRoot, '.agents')), { code: 'ENOENT' }); + const refreshed: SkillLocationsSnapshot = await list({} as never); + assert.ok(refreshed.locations + .filter(({ scope }) => scope !== 'project') + .every(({ status }) => status === 'available'), + ); + } finally { + catalog.close(); + await rm(root, { recursive: true, force: true }); + } + }); +} + +test("Skill location contexts reject another scope, a remounted root, and a replacement Host", async () => { + const root = await mkdtemp(join(tmpdir(), 'maka-skill-location-scope-')); + const firstRoot = join(root, 'first'); + const secondRoot = join(root, 'second'); + const workspaceRoot = join(root, 'workspace'); + const homeDirectory = join(root, 'home'); + await Promise.all([firstRoot, secondRoot].map((path) => mkdir(path))); + await symlink(firstRoot, workspaceRoot, 'junction'); + await symlink(firstRoot, homeDirectory, 'junction'); + const opened: string[] = []; + const register = () => { + const handlers = new Map(); + registerRuntimeHostSkillsIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: new Proxy({}, { get() { throw new Error('Host Skill catalog is unavailable'); } }) as DesktopRuntimeHostClient, + workspaceRoot, + homeDirectory, + mainWindowController: {} as never, + getSelectedWorkspaceTarget: async () => { throw new Error('Project is unavailable'); }, + getSelectedProject: async () => { throw new Error('Project is unavailable'); }, + resolveNewSessionWorkspaceTarget: async () => undefined, + getDefaultPermissionMode: async () => 'ask', + resolveLocale: async () => 'en', + openPath: async (path) => { opened.push(path); return ''; }, + }); + const list = handlers.get('skills:locations:list'); + const open = handlers.get('skills:locations:open'); + assert.ok(list); + assert.ok(open); + return { + list: async (): Promise => list({} as never), + open: (ref: string, contextId: string | undefined) => + open({} as never, ref, { contextId, createIfMissing: true }), + }; + }; + try { + const host = register(); + const initial = await host.list(); + assert.deepEqual(initial.locations.map(({ ref }) => ref), ['workspace:legacy', 'user:maka', 'user:agents']); + assert.deepEqual(await host.open('user:agents', initial.contextIds.workspace), { ok: false, reason: 'stale_context' }); + assert.deepEqual(await host.open('workspace:legacy', initial.contextIds.user), { ok: false, reason: 'stale_context' }); + assert.deepEqual(await host.open('../outside', initial.contextIds.user), { ok: false, reason: 'unknown_location' }); + + await rename(workspaceRoot, join(root, 'previous-workspace')); + await rename(homeDirectory, join(root, 'previous-home')); + await symlink(secondRoot, workspaceRoot, 'junction'); + await symlink(secondRoot, homeDirectory, 'junction'); + assert.deepEqual(await host.open('workspace:legacy', initial.contextIds.workspace), { ok: false, reason: 'stale_context' }); + assert.deepEqual(await host.open('user:agents', initial.contextIds.user), { ok: false, reason: 'stale_context' }); + + const remounted = await host.list(); + const replacement = register(); + assert.deepEqual(await replacement.open('workspace:legacy', remounted.contextIds.workspace), { ok: false, reason: 'stale_context' }); + assert.deepEqual(await replacement.open('user:agents', remounted.contextIds.user), { ok: false, reason: 'stale_context' }); + assert.deepEqual(opened, []); + await assert.rejects(lstat(join(secondRoot, 'skills')), { code: 'ENOENT' }); + await assert.rejects(lstat(join(secondRoot, '.agents')), { code: 'ENOENT' }); + + const current = await replacement.list(); + assert.deepEqual(await replacement.open('workspace:legacy', current.contextIds.workspace), { ok: true }); + assert.deepEqual(await replacement.open('user:agents', current.contextIds.user), { ok: true }); + assert.deepEqual(opened, [await realpath(join(secondRoot, 'skills')), await realpath(join(secondRoot, '.agents', 'skills'))]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 32e7a08bc6..8c6c4645b6 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1768,6 +1768,7 @@ function registerHostClientIpc( workspaceRoot, mainWindowController, getSelectedWorkspaceTarget: () => selectedDesktopWorkspaceTarget(target), + getSelectedProject: () => requireRuntimePolicyTarget(target).projectManagement.current(), resolveNewSessionWorkspaceTarget: async (projectId) => { if (typeof projectId === "string") { return { kind: "project", projectId }; diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 9db08cfcca..40076d21ce 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -17,7 +17,9 @@ * under the License. */ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; +import { realpath } from 'node:fs/promises'; +import { homedir } from 'node:os'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import { resolveSkillDiscoveryPaths, scanSkillsWithDiagnostics } from '@maka/runtime/skills'; import { type InvocableSkillEntry } from '@maka/runtime/skill-invocation'; @@ -37,6 +39,7 @@ import type { ManagedSkillUpdatePreview, SkillEntry, SkillGovernanceDetails, + SkillLocation, } from "@maka/ui"; import type { OpenSkillLocationResult, SkillLocationsSnapshot } from '../shared/skill-locations.js'; import type { createMainWindowController } from "./main-window.js"; @@ -51,7 +54,13 @@ import type { import type { UiLocale } from "@maka/core/ui-locale"; import { nativeFileDialogCopy } from "./native-file-dialog-copy.js"; import { resolveSkillOpenPath } from "./skill-open-path.js"; -import { listSkillLocations, resolveSkillLocation } from "./skill-locations.js"; +import { + listSkillLocations, + resolveSkillLocation, + skillLocationScope, + type SkillLocationContext, +} from "./skill-locations.js"; +import type { CurrentProjectSelection } from './project-root-controller.js'; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -67,12 +76,14 @@ interface RuntimeHostSkillsIpcDeps { readonly workspaceRoot: string; readonly mainWindowController: MainWindowController; readonly getSelectedWorkspaceTarget: () => Promise; + readonly getSelectedProject: () => Promise; readonly resolveNewSessionWorkspaceTarget: ( projectId: string | null | undefined, ) => Promise; readonly getDefaultPermissionMode: () => Promise; readonly openPath: (path: string) => Promise; readonly allowLocalPaths?: boolean; + readonly homeDirectory?: string; readonly resolveLocale: () => Promise; } @@ -96,6 +107,8 @@ interface StableSkillMutation { export function registerRuntimeHostSkillsIpc( deps: RuntimeHostSkillsIpcDeps, ): void { + // A replacement Host registration cannot reuse an old location snapshot. + const locationContextSeed = randomUUID(); handleReconnectableRead(deps.ipcMain, "skills:list", async () => { const workspace = await deps.getSelectedWorkspaceTarget(); if (!workspace) return []; @@ -185,16 +198,17 @@ export function registerRuntimeHostSkillsIpc( }); handleReconnectableRead(deps.ipcMain, "skills:locations:list", async (): Promise => { - if (deps.allowLocalPaths === false) return { contextId: null, locations: [] }; - const workspace = await deps.getSelectedWorkspaceTarget(); - if (!workspace) return { contextId: null, locations: [] }; - const snapshot = await deps.client.loadSkillCatalog({ workspace }, "governance"); + if (deps.allowLocalPaths === false) return { contextIds: {}, locations: [] }; + const context = await readSkillLocationContext(deps, true); + const [locations, project, workspace, user] = await Promise.all([ + listSkillLocations(context), + skillLocationContextId(context, 'project', locationContextSeed), + skillLocationContextId(context, 'workspace', locationContextSeed), + skillLocationContextId(context, 'user', locationContextSeed), + ]); return { - contextId: skillLocationContextId(snapshot.workspace, deps.workspaceRoot), - locations: await listSkillLocations({ - projectRoot: snapshot.workspace.hostCwd, - workspaceRoot: deps.workspaceRoot, - }), + contextIds: { project, workspace, user }, + locations, }; }); @@ -204,16 +218,15 @@ export function registerRuntimeHostSkillsIpc( if (deps.allowLocalPaths === false) { return { ok: false as const, reason: "blocked_path" as const }; } - const workspace = await requireSelectedWorkspaceTarget(deps); - const snapshot = await deps.client.loadSkillCatalog({ workspace }, "governance"); - if (options?.contextId !== skillLocationContextId(snapshot.workspace, deps.workspaceRoot)) { + const scope = skillLocationScope(ref); + if (!scope) return { ok: false, reason: 'unknown_location' }; + const context = await readSkillLocationContext(deps, scope === 'project'); + const contextId = await skillLocationContextId(context, scope, locationContextSeed); + if (!contextId || options?.contextId !== contextId) { return { ok: false, reason: "stale_context" }; } const resolved = await resolveSkillLocation( - { - projectRoot: snapshot.workspace.hostCwd, - workspaceRoot: deps.workspaceRoot, - }, + context, ref, options?.createIfMissing === true, ); @@ -392,9 +405,35 @@ export function registerRuntimeHostSkillsIpc( ); } -function skillLocationContextId(workspace: WorkspaceProjection, workspaceRoot: string): string { +type LocalSkillLocationContext = SkillLocationContext & { readonly projectId: string | null }; + +async function readSkillLocationContext( + deps: RuntimeHostSkillsIpcDeps, + includeProject: boolean, +): Promise { + // Project lookup failure affects only Project locations, never the other roots. + const project = includeProject ? await deps.getSelectedProject().catch(() => null) : null; + return { + projectId: project?.projectId ?? null, + projectRoot: project?.path ?? null, + workspaceRoot: deps.workspaceRoot, + homeDirectory: deps.homeDirectory ?? homedir(), + }; +} + +async function skillLocationContextId( + context: LocalSkillLocationContext, + scope: SkillLocation['scope'], + hostRegistration: string, +): Promise { + const root = scope === 'project' + ? context.projectRoot + : scope === 'workspace' ? context.workspaceRoot : context.homeDirectory; + if (!root) return undefined; + const canonicalRoot = await realpath(root).catch(() => undefined); + if (!canonicalRoot) return undefined; return createHash('sha256') - .update(JSON.stringify([workspace.target, workspace.hostCwd, workspaceRoot])) + .update(JSON.stringify([hostRegistration, scope, canonicalRoot, scope === 'project' ? context.projectId : null])) .digest('hex'); } diff --git a/apps/desktop/src/main/skill-locations.ts b/apps/desktop/src/main/skill-locations.ts index 2d3a657245..6195edc8b6 100644 --- a/apps/desktop/src/main/skill-locations.ts +++ b/apps/desktop/src/main/skill-locations.ts @@ -37,11 +37,26 @@ export type ResolveSkillLocationResult = }; export interface SkillLocationContext { - readonly projectRoot: string; + readonly projectRoot: string | null; readonly workspaceRoot: string; readonly homeDirectory?: string; } +export function skillLocationScope(ref: string): SkillLocation['scope'] | undefined { + switch (ref) { + case 'project:maka': + case 'project:agents': + return 'project'; + case 'workspace:legacy': + return 'workspace'; + case 'user:maka': + case 'user:agents': + return 'user'; + default: + return undefined; + } +} + export async function listSkillLocations( context: SkillLocationContext, ): Promise { @@ -71,6 +86,11 @@ export async function resolveSkillLocation( ref: string, createIfMissing: boolean, ): Promise { + const scope = skillLocationScope(ref); + if (!scope) return { ok: false, reason: 'unknown_location' }; + if (scope === 'project' && context.projectRoot === null) { + return { ok: false, reason: 'read_failed' }; + } const discovery = resolveSkillDiscoveryPaths( context.projectRoot, context.workspaceRoot, diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index 1928cbea6c..8906a5ef44 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -860,8 +860,9 @@ export function useSkillsController( ref: SkillLocationRef, createIfMissing: boolean, ): Promise => { - const contextId = skillLocationSnapshot?.contextId; - if (!contextId) return; + const location = skillLocationSnapshot?.locations.find((entry) => entry.ref === ref); + const contextId = location && skillLocationSnapshot?.contextIds[location.scope]; + if (!contextId || !skillLocationSnapshot) return; const isCurrent = () => isSkillsSurfaceActive() && inputRef.current.clientPathsAccessible && @@ -939,7 +940,7 @@ export function useSkillsController( ...(input.clientPathsAccessible ? { onOpenSkill: openSkill, - ...(skillLocationSnapshot?.contextId + ...(skillLocationSnapshot?.locations.length ? { onOpenSkillLocation: openSkillLocation } : {}), onImportManagedSkillSource: importManagedSkillSource, diff --git a/apps/desktop/src/renderer/features/module-hub/testing.ts b/apps/desktop/src/renderer/features/module-hub/testing.ts index dc9729568c..6472acd684 100644 --- a/apps/desktop/src/renderer/features/module-hub/testing.ts +++ b/apps/desktop/src/renderer/features/module-hub/testing.ts @@ -138,7 +138,7 @@ export function createFakeModuleHubServices( }, skills: { list: async () => [], - listLocations: async () => ({ contextId: null, locations: [] }), + listLocations: async () => ({ contextIds: {}, locations: [] }), listManagedSources: async () => [], listBundledCatalog: async () => [], importManagedSource: async () => diff --git a/apps/desktop/src/shared/skill-locations.d.ts b/apps/desktop/src/shared/skill-locations.d.ts index 601960eca4..64b08c59a6 100644 --- a/apps/desktop/src/shared/skill-locations.d.ts +++ b/apps/desktop/src/shared/skill-locations.d.ts @@ -20,8 +20,8 @@ import type { SkillLocation } from '@maka/ui'; export interface SkillLocationsSnapshot { - /** Opaque identity of the workspace that supplied these locations. */ - readonly contextId: string | null; + /** Opaque identities bound to this Host and each independently resolved scope. */ + readonly contextIds: Readonly>>; readonly locations: SkillLocation[]; } diff --git a/packages/runtime/src/skills-discovery.ts b/packages/runtime/src/skills-discovery.ts index f2ba5b73cd..f84267a4c6 100644 --- a/packages/runtime/src/skills-discovery.ts +++ b/packages/runtime/src/skills-discovery.ts @@ -175,21 +175,31 @@ export interface RejectedSkillDefinition { * * Returns containment roots so `scanSkillDir` can reject ancestor-level * symlink escapes (e.g. `repo/.agents -> /outside`). + * A null cwd omits Project locations without substituting another scope's root. */ export function resolveSkillDiscoveryPaths( - cwd: string, + cwd: string | null, workspaceRoot: string, homeDir?: string, ): { entries: SkillDiscoveryEntry[]; dirs: string[]; stateRoot: string } { const home = homeDir ?? homedir(); const entries: SkillDiscoveryEntry[] = [ - { dir: join(cwd, '.maka', 'skills'), containmentRoot: cwd, scope: 'project', source: 'maka' }, - { - dir: join(cwd, '.agents', 'skills'), - containmentRoot: cwd, - scope: 'project', - source: 'agents', - }, + ...(cwd === null + ? [] + : [ + { + dir: join(cwd, '.maka', 'skills'), + containmentRoot: cwd, + scope: 'project' as const, + source: 'maka' as const, + }, + { + dir: join(cwd, '.agents', 'skills'), + containmentRoot: cwd, + scope: 'project' as const, + source: 'agents' as const, + }, + ]), { dir: join(workspaceRoot, 'skills'), containmentRoot: workspaceRoot, From cdca2eb15237d7abdad5d24dc3a66d4454de7f3e Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 18 Sep 2026 21:54:21 +0800 Subject: [PATCH 5/7] fix(desktop): address Skill location review feedback Retire the legacy Skills open-path key, share standard location identities across Runtime and Desktop, and display canonical opening paths. Refresh invalid location snapshots without retrying mutations, skip remote location reads, defer menu actions, and extend IPC/controller/UI regression coverage. Generated-by: Codex --- apps/desktop/renderer-architecture.json | 2 +- .../expected-error-presentation.test.ts | 6 +- .../module-hub-skills-controller.test.ts | 156 +++++++++++++++++- .../main/__tests__/open-path-guard.test.ts | 29 ++-- .../runtime-host-skills-ipc-main.test.ts | 124 +++++++++++++- apps/desktop/src/main/open-path-guard.ts | 5 +- .../src/main/runtime-host-skills-ipc-main.ts | 4 +- apps/desktop/src/main/skill-locations.ts | 48 ++---- apps/desktop/src/preload/bridge-contract.d.ts | 2 +- apps/desktop/src/preload/preload.ts | 2 +- apps/desktop/src/renderer/app-shell-copy.ts | 2 +- .../controller/use-skills-controller.ts | 32 ++-- .../src/renderer/locales/shell-copy.ts | 16 +- apps/desktop/src/renderer/open-path.ts | 2 +- docs/windows-test-inventory.md | 5 +- packages/core/package.json | 1 + packages/core/src/skill-locations.ts | 34 ++++ packages/runtime/src/__tests__/skills.test.ts | 19 ++- packages/runtime/src/skills-discovery.ts | 52 +++--- .../__tests__/skills-panel-locations.test.tsx | 32 +++- packages/ui/src/module-panel-types.ts | 14 +- packages/ui/src/skills-panel.tsx | 2 +- 22 files changed, 450 insertions(+), 139 deletions(-) create mode 100644 packages/core/src/skill-locations.ts diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 8057b737f4..5b1b54c1f7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -419,7 +419,7 @@ "@maka/core/redaction": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 393 + "nonTriviaTokens": 391 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, diff --git a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts index 76a8e9e855..5728426091 100644 --- a/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts +++ b/apps/desktop/src/main/__tests__/expected-error-presentation.test.ts @@ -173,10 +173,10 @@ test('every shell error-copy entry classifies, and keeps its contextual fallback assert.equal(messageRefreshErrorMessage(timeout, 'zh-CN'), '请求超时'); assert.equal(messageRefreshErrorMessage(opaque, 'zh-CN'), copy.errors.messageRefresh); - assert.equal(openPathActionErrorMessage(timeout, 'skills', 'zh-CN'), '请求超时'); + assert.equal(openPathActionErrorMessage(timeout, 'workspace', 'zh-CN'), '请求超时'); assert.equal( - openPathActionErrorMessage(opaque, 'skills', 'zh-CN'), - copy.errors.openPath(copy.paths.skills), + openPathActionErrorMessage(opaque, 'workspace', 'zh-CN'), + copy.errors.openPath(copy.paths.workspace), ); // The connection test derives its own category from the status code, so an diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts index 3888de8ed9..9b5123c69b 100644 --- a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -22,7 +22,7 @@ import assert from "node:assert/strict"; import { afterEach, test } from "node:test"; import { act, createElement } from "react"; import type { SkillEntry, ToastApi } from "@maka/ui"; -import type { SkillLocationsSnapshot } from "../../shared/skill-locations.js"; +import type { OpenSkillLocationResult, SkillLocationsSnapshot } from "../../shared/skill-locations.js"; import { cleanupFakeDom, installReactRenderer } from "./fake-dom.js"; import { createFakeModuleHubServices, @@ -177,7 +177,7 @@ test("Skills projections have independent same-Host generation and default-Host }, }); - await act(async () => renderController(root, services, input(records))); + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); let first: Promise; await act(async () => { first = controller().host.onRefreshSkills(); }); await act(async () => controller().host.onRefreshSkills()); @@ -484,7 +484,7 @@ test("stale Skills refresh errors do not outlive a newer successful generation", }, }); - await act(async () => renderController(root, services, input(records))); + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); let staleRefresh: Promise; await act(async () => { staleRefresh = controller().host.onRefreshSkills(); @@ -662,3 +662,153 @@ test("independent Skill locations stay actionable when the Project has no usable { ref: 'user:agents', contextId: 'user-context', createIfMissing: true }, ]); }); + +for (const reason of ['stale_context', 'missing'] as const) { + test(`refreshes Skill locations after ${reason} without retrying the open action`, async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const opened: unknown[] = []; + const defaults = createFakeModuleHubServices(); + let locationReads = 0; + const services = createFakeModuleHubServices({ + skills: { + ...defaults.skills, + listLocations: async () => { + locationReads += 1; + return skillLocations(locationReads === 1 ? 'project-a' : 'project-b'); + }, + openLocation: async (ref, options) => { + opened.push({ ref, ...options }); + return { ok: false, reason }; + }, + }, + }); + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); + await act(async () => controller().refreshProjectSkills()); + const openPreviousLocation = controller().host.onOpenSkillLocation; + assert.ok(openPreviousLocation); + + await act(async () => openPreviousLocation('project:agents', false)); + assert.equal(controller().host.skillLocations[0]?.path, '/project-b/.agents/skills'); + assert.equal(locationReads, 2); + assert.deepEqual(opened, [{ ref: 'project:agents', contextId: 'project-a', createIfMissing: false }]); + assert.equal(records.length, 1); + assert.equal(records[0]?.kind, 'error'); + + await act(async () => openPreviousLocation('project:agents', true)); + assert.equal(opened.length, 1); + await act(async () => controller().host.onOpenSkillLocation?.('project:agents', true)); + assert.deepEqual(opened[1], { ref: 'project:agents', contextId: 'project-b', createIfMissing: true }); + }); +} + +test('remote Skills refreshes skip location IPC and clear local directory actions', async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const defaults = createFakeModuleHubServices(); + let locationReads = 0; + const services = createFakeModuleHubServices({ + skills: { + ...defaults.skills, + list: async () => [skill('host-skill')], + listLocations: async () => { + locationReads += 1; + return skillLocations('local-project'); + }, + }, + }); + await act(async () => renderController(root, services, input(records))); + await act(async () => controller().refreshProjectSkills()); + assert.equal(locationReads, 0); + assert.equal(controller().host.skills[0]?.id, 'host-skill'); + assert.deepEqual(controller().host.skillLocations, []); + assert.equal(controller().host.onOpenSkillLocation, undefined); + + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); + await act(async () => controller().refreshProjectSkills()); + assert.equal(locationReads, 1); + assert.equal(controller().host.skillLocations[0]?.path, '/local-project/.agents/skills'); + await act(async () => renderController(root, services, input(records))); + await act(async () => controller().host.onRefreshSkills()); + assert.equal(locationReads, 1); + assert.deepEqual(controller().host.skillLocations, []); + assert.equal(controller().host.onOpenSkillLocation, undefined); + assert.deepEqual(records, []); +}); + +for (const transition of ['Host', 'surface', 'generation', 'capability'] as const) { + test(`a late Skill location failure cannot refresh after its ${transition} changes`, async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const defaults = createFakeModuleHubServices(); + const failure = deferred(); + let defaultHost = { profileId: 'profile-a', hostId: 'host-a' }; + let locationReads = 0; + const services = createFakeModuleHubServices({ + runtimeHosts: { ...defaults.runtimeHosts, getDefault: async () => defaultHost }, + skills: { + ...defaults.skills, + listLocations: async () => { + locationReads += 1; + return skillLocations(`project-${locationReads}`); + }, + openLocation: async () => failure.promise, + }, + }); + const activeInput = input(records, { clientPathsAccessible: true }); + await act(async () => renderController(root, services, activeInput)); + await act(async () => controller().refreshProjectSkills()); + let pending: Promise | undefined; + await act(async () => { pending = controller().host.onOpenSkillLocation?.('project:agents', false); }); + if (transition === 'Host') defaultHost = { profileId: 'profile-b', hostId: 'host-b' }; + if (transition === 'surface') { + await act(async () => renderController(root, services, { ...activeInput, active: false })); + } + if (transition === 'capability') { + await act(async () => renderController(root, services, { ...activeInput, clientPathsAccessible: false })); + } + if (transition === 'generation') await act(async () => controller().host.onRefreshSkills()); + const readsBeforeFailure = locationReads; + await act(async () => { + failure.resolve({ ok: false, reason: 'stale_context' }); + await pending; + }); + assert.equal(locationReads, readsBeforeFailure); + assert.deepEqual(records, []); + }); +} + +for (const stage of ['Host lookup', 'location response'] as const) { + test(`revoking local paths during ${stage} suppresses pending Skill locations`, async () => { + const { root } = installReactRenderer(); + const records: ToastRecord[] = []; + const defaults = createFakeModuleHubServices(); + const host = { profileId: 'profile-a', hostId: 'host-a' }; + const hostRead = deferred(); + const locationRead = deferred(); + let locationReads = 0; + const services = createFakeModuleHubServices({ + runtimeHosts: { + ...defaults.runtimeHosts, + getDefault: async () => stage === 'Host lookup' ? hostRead.promise : host, + }, + skills: { + ...defaults.skills, + listLocations: async () => { locationReads += 1; return locationRead.promise; }, + }, + }); + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); + let pending: Promise; + await act(async () => { pending = controller().refreshProjectSkills(); }); + await act(async () => renderController(root, services, input(records))); + await act(async () => { + hostRead.resolve(host); + locationRead.resolve(skillLocations('local-project')); + await pending; + }); + assert.equal(locationReads, stage === 'Host lookup' ? 0 : 1); + assert.deepEqual(controller().host.skillLocations, []); + assert.equal(controller().host.onOpenSkillLocation, undefined); + assert.deepEqual(records, []); + }); +} diff --git a/apps/desktop/src/main/__tests__/open-path-guard.test.ts b/apps/desktop/src/main/__tests__/open-path-guard.test.ts index ccc26fd2d3..eba2a8b564 100644 --- a/apps/desktop/src/main/__tests__/open-path-guard.test.ts +++ b/apps/desktop/src/main/__tests__/open-path-guard.test.ts @@ -25,17 +25,24 @@ import { describe, test } from 'node:test'; import { resolveOpenPath } from '../open-path-guard.js'; describe('open path guard', () => { + test('rejects the retired Skills key even when the compatibility folder exists', async () => { + await withWorkspace(async (workspaceRoot) => { + await mkdir(join(workspaceRoot, 'skills')); + assert.deepEqual(await resolveOpenPath({ key: 'skills', workspaceRoot }), { + ok: false, + reason: 'unknown-key', + }); + }); + }); + test('resolves known allowlisted keys inside workspace', async () => { await withWorkspace(async (workspaceRoot) => { - await mkdir(join(workspaceRoot, 'skills'), { recursive: true }); await mkdir(join(workspaceRoot, 'memory'), { recursive: true }); const workspace = await resolveOpenPath({ key: 'workspace', workspaceRoot }); - const skills = await resolveOpenPath({ key: 'skills', workspaceRoot }); const memory = await resolveOpenPath({ key: 'memory', workspaceRoot }); assert.equal(workspace.ok, true); - assert.equal(skills.ok, true); assert.equal(memory.ok, true); }); }); @@ -58,11 +65,11 @@ describe('open path guard', () => { test('rejects unknown keys, missing targets, and files', async () => { await withWorkspace(async (workspaceRoot) => { assert.deepEqual(await resolveOpenPath({ key: 'unknown', workspaceRoot }), { ok: false, reason: 'unknown-key' }); - assert.deepEqual(await resolveOpenPath({ key: 'skills', workspaceRoot }), { ok: false, reason: 'missing' }); + assert.deepEqual(await resolveOpenPath({ key: 'memory', workspaceRoot }), { ok: false, reason: 'missing' }); - await writeFile(join(workspaceRoot, 'skills'), 'not a directory', 'utf8'); - assert.deepEqual(await resolveOpenPath({ key: 'skills', workspaceRoot }), { ok: false, reason: 'not-a-directory' }); - assert.deepEqual(await resolveOpenPath({ key: 'project', workspaceRoot, projectRoot: join(workspaceRoot, 'skills') }), { ok: false, reason: 'not-a-directory' }); + await writeFile(join(workspaceRoot, 'memory'), 'not a directory', 'utf8'); + assert.deepEqual(await resolveOpenPath({ key: 'memory', workspaceRoot }), { ok: false, reason: 'not-a-directory' }); + assert.deepEqual(await resolveOpenPath({ key: 'project', workspaceRoot, projectRoot: join(workspaceRoot, 'memory') }), { ok: false, reason: 'not-a-directory' }); }); }); @@ -77,9 +84,9 @@ describe('open path guard', () => { test('rejects symlink escapes from inside an allowed directory', async () => { await withWorkspace(async (workspaceRoot, outsideRoot) => { await mkdir(outsideRoot, { recursive: true }); - await symlink(outsideRoot, join(workspaceRoot, 'skills')); + await symlink(outsideRoot, join(workspaceRoot, 'memory')); - assert.deepEqual(await resolveOpenPath({ key: 'skills', workspaceRoot }), { ok: false, reason: 'not-allowed' }); + assert.deepEqual(await resolveOpenPath({ key: 'memory', workspaceRoot }), { ok: false, reason: 'not-allowed' }); }); }); @@ -88,10 +95,10 @@ describe('open path guard', () => { const linkRoot = await mkdtemp(join(tmpdir(), 'maka-open-path-link-parent-')); const workspaceLink = join(linkRoot, 'workspace-link'); try { - await mkdir(join(realRoot, 'skills'), { recursive: true }); + await mkdir(join(realRoot, 'memory'), { recursive: true }); await symlink(realRoot, workspaceLink); - const result = await resolveOpenPath({ key: 'skills', workspaceRoot: workspaceLink }); + const result = await resolveOpenPath({ key: 'memory', workspaceRoot: workspaceLink }); assert.equal(result.ok, true); } finally { diff --git a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts index acc6caee8a..66c32963bb 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts @@ -18,7 +18,7 @@ */ import assert from "node:assert/strict"; -import { lstat, mkdir, mkdtemp, realpath, rename, rm, symlink } from "node:fs/promises"; +import { chmod, lstat, mkdir, mkdtemp, realpath, rename, rm, symlink } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -26,7 +26,7 @@ import type { WorkspaceTarget } from "@maka/runtime-host/protocol"; import { createProjectCatalog } from "@maka/storage/project-catalog"; import { HostProjectMembershipGate } from "../../../../../packages/runtime-host/dist/server/project-membership-gate.js"; import { HostWorkspaceResolver } from "../../../../../packages/runtime-host/dist/server/workspace-resolver.js"; -import type { SkillLocationsSnapshot } from "../../shared/skill-locations.js"; +import type { OpenSkillLocationResult, SkillLocationsSnapshot } from "../../shared/skill-locations.js"; import type { IpcHandler } from "../ipc-reconnect-policy.js"; import { createProjectManagementService } from "../project-management-service.js"; import type { CurrentProjectSelection } from "../project-root-controller.js"; @@ -409,6 +409,12 @@ test("Skill location contexts reject another scope, a remounted root, and a repl const host = register(); const initial = await host.list(); assert.deepEqual(initial.locations.map(({ ref }) => ref), ['workspace:legacy', 'user:maka', 'user:agents']); + const canonicalFirstRoot = await realpath(firstRoot); + assert.deepEqual(initial.locations.map(({ path, status }) => ({ path, status })), [ + { path: join(canonicalFirstRoot, 'skills'), status: 'missing' }, + { path: join(canonicalFirstRoot, '.maka', 'skills'), status: 'missing' }, + { path: join(canonicalFirstRoot, '.agents', 'skills'), status: 'missing' }, + ]); assert.deepEqual(await host.open('user:agents', initial.contextIds.workspace), { ok: false, reason: 'stale_context' }); assert.deepEqual(await host.open('workspace:legacy', initial.contextIds.user), { ok: false, reason: 'stale_context' }); assert.deepEqual(await host.open('../outside', initial.contextIds.user), { ok: false, reason: 'unknown_location' }); @@ -432,7 +438,121 @@ test("Skill location contexts reject another scope, a remounted root, and a repl assert.deepEqual(await replacement.open('workspace:legacy', current.contextIds.workspace), { ok: true }); assert.deepEqual(await replacement.open('user:agents', current.contextIds.user), { ok: true }); assert.deepEqual(opened, [await realpath(join(secondRoot, 'skills')), await realpath(join(secondRoot, '.agents', 'skills'))]); + const available = await replacement.list(); + assert.deepEqual( + available.locations.filter(({ status }) => status === 'available').map(({ path }) => path), + opened, + ); } finally { await rm(root, { recursive: true, force: true }); } }); + +test('reports open_failed when the native shell cannot open a Skill directory', async () => { + await withLocationIpc(async ({ workspaceRoot, list, open, opened }) => { + await mkdir(join(workspaceRoot, 'skills')); + const snapshot = await list(); + assert.deepEqual(await open('workspace:legacy', { + contextId: snapshot.contextIds.workspace, + createIfMissing: false, + }), { ok: false, reason: 'open_failed' }); + assert.deepEqual(opened, [await realpath(join(workspaceRoot, 'skills'))]); + }, 'The file manager could not open the directory'); +}); + +test('reports create_failed without opening when a Skill directory parent is not writable', { + skip: process.platform === 'win32' + ? 'POSIX permissions are required to make the Skill directory parent read-only' + : process.getuid?.() === 0, +}, async () => { + await withLocationIpc(async ({ workspaceRoot, list, open, opened }) => { + const snapshot = await list(); + await chmod(workspaceRoot, 0o500); + try { + assert.deepEqual(await open('workspace:legacy', { + contextId: snapshot.contextIds.workspace, + createIfMissing: true, + }), { ok: false, reason: 'create_failed' }); + assert.deepEqual(opened, []); + const current = await list(); + assert.equal(current.locations.find(({ ref }) => ref === 'workspace:legacy')?.status, 'missing'); + } finally { + await chmod(workspaceRoot, 0o700); + } + }); +}); + +test('blocks leaf-symlink Skill directories at IPC even when their target is contained', async () => { + await withLocationIpc(async ({ root, workspaceRoot, homeDirectory, list, open, opened }) => { + const contained = join(workspaceRoot, 'contained'); + const outside = join(root, 'outside'); + await Promise.all([contained, outside, join(homeDirectory, '.agents')].map((path) => mkdir(path))); + await symlink(contained, join(workspaceRoot, 'skills'), 'junction'); + await symlink(outside, join(homeDirectory, '.agents', 'skills'), 'junction'); + const snapshot = await list(); + for (const [ref, scope] of [['workspace:legacy', 'workspace'], ['user:agents', 'user']] as const) { + assert.equal(snapshot.locations.find((location) => location.ref === ref)?.status, 'blocked_path'); + for (const createIfMissing of [false, true]) { + assert.deepEqual(await open(ref, { + contextId: snapshot.contextIds[scope], + createIfMissing, + }), { ok: false, reason: 'blocked_path' }); + } + } + assert.deepEqual(opened, []); + }); +}); + +async function withLocationIpc( + run: (fixture: { + root: string; + workspaceRoot: string; + homeDirectory: string; + opened: string[]; + list: () => Promise; + open: ( + ref: string, + options: { contextId?: string; createIfMissing?: boolean }, + ) => Promise; + }) => Promise, + openPathError = '', +): Promise { + const root = await mkdtemp(join(tmpdir(), 'maka-skill-location-ipc-')); + const workspaceRoot = join(root, 'workspace'); + const homeDirectory = join(root, 'home'); + const handlers = new Map(); + const opened: string[] = []; + try { + await Promise.all([workspaceRoot, homeDirectory].map((path) => mkdir(path))); + registerRuntimeHostSkillsIpc({ + ipcMain: { + handle: (channel, listener) => handlers.set(channel, listener), + handleReconnectableRead: (channel, listener) => handlers.set(channel, listener), + }, + client: new Proxy({}, { get() { throw new Error('Location operations must not read the Host catalog'); } }) as DesktopRuntimeHostClient, + workspaceRoot, + homeDirectory, + mainWindowController: {} as never, + getSelectedWorkspaceTarget: async () => undefined, + getSelectedProject: async () => { throw new Error('No Project selected'); }, + resolveNewSessionWorkspaceTarget: async () => undefined, + getDefaultPermissionMode: async () => 'ask', + resolveLocale: async () => 'en', + openPath: async (path) => { opened.push(path); return openPathError; }, + }); + const list = handlers.get('skills:locations:list'); + const open = handlers.get('skills:locations:open'); + assert.ok(list); + assert.ok(open); + await run({ + root, + workspaceRoot, + homeDirectory, + opened, + list: () => list({} as never), + open: (ref, options) => open({} as never, ref, options), + }); + } finally { + await rm(root, { recursive: true, force: true }); + } +} diff --git a/apps/desktop/src/main/open-path-guard.ts b/apps/desktop/src/main/open-path-guard.ts index 9f5b098535..658a094e0b 100644 --- a/apps/desktop/src/main/open-path-guard.ts +++ b/apps/desktop/src/main/open-path-guard.ts @@ -20,7 +20,7 @@ import { realpath, stat } from 'node:fs/promises'; import { join, resolve, relative, sep } from 'node:path'; -export type OpenPathKey = 'workspace' | 'skills' | 'memory' | 'project'; +export type OpenPathKey = 'workspace' | 'memory' | 'project'; export type OpenPathResult = | { ok: true; opened: OpenPathKey } @@ -41,7 +41,6 @@ export interface ResolveOpenPathInput { const OPEN_PATHS: Record, (workspaceRoot: string) => string> = { workspace: (workspaceRoot) => workspaceRoot, - skills: (workspaceRoot) => join(workspaceRoot, 'skills'), memory: (workspaceRoot) => join(workspaceRoot, 'memory'), }; @@ -80,7 +79,7 @@ export async function resolveOpenPath(input: ResolveOpenPathInput): Promise< } function isOpenPathKey(value: string): value is OpenPathKey { - return value === 'workspace' || value === 'skills' || value === 'memory' || value === 'project'; + return value === 'workspace' || value === 'memory' || value === 'project'; } function isInsideOrSamePath(root: string, target: string): boolean { diff --git a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts index 40076d21ce..4a03fd831a 100644 --- a/apps/desktop/src/main/runtime-host-skills-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-skills-ipc-main.ts @@ -20,6 +20,7 @@ import { createHash, randomUUID } from 'node:crypto'; import { realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; +import { findSkillLocation } from '@maka/core/skill-locations'; import type { ChatDefaultPermissionMode } from '@maka/core/settings'; import { resolveSkillDiscoveryPaths, scanSkillsWithDiagnostics } from '@maka/runtime/skills'; import { type InvocableSkillEntry } from '@maka/runtime/skill-invocation'; @@ -57,7 +58,6 @@ import { resolveSkillOpenPath } from "./skill-open-path.js"; import { listSkillLocations, resolveSkillLocation, - skillLocationScope, type SkillLocationContext, } from "./skill-locations.js"; import type { CurrentProjectSelection } from './project-root-controller.js'; @@ -218,7 +218,7 @@ export function registerRuntimeHostSkillsIpc( if (deps.allowLocalPaths === false) { return { ok: false as const, reason: "blocked_path" as const }; } - const scope = skillLocationScope(ref); + const scope = findSkillLocation(ref)?.scope; if (!scope) return { ok: false, reason: 'unknown_location' }; const context = await readSkillLocationContext(deps, scope === 'project'); const contextId = await skillLocationContextId(context, scope, locationContextSeed); diff --git a/apps/desktop/src/main/skill-locations.ts b/apps/desktop/src/main/skill-locations.ts index 6195edc8b6..11157ef462 100644 --- a/apps/desktop/src/main/skill-locations.ts +++ b/apps/desktop/src/main/skill-locations.ts @@ -20,13 +20,13 @@ import { lstat, mkdir, opendir, realpath } from 'node:fs/promises'; import { homedir } from 'node:os'; import { resolve } from 'node:path'; +import { findSkillLocation } from '@maka/core/skill-locations'; import { isPathInside, realpathAllowMissing } from '@maka/runtime/path-containment'; import { resolveSkillDiscoveryPaths, scanSkillsWithDiagnostics, - type SkillDiscoveryEntry, } from '@maka/runtime/skills'; -import type { SkillLocation, SkillLocationRef } from '@maka/ui'; +import type { SkillLocation } from '@maka/ui'; import { withSkillLocationCounts } from '../shared/skill-location-counts.js'; export type ResolveSkillLocationResult = @@ -42,21 +42,6 @@ export interface SkillLocationContext { readonly homeDirectory?: string; } -export function skillLocationScope(ref: string): SkillLocation['scope'] | undefined { - switch (ref) { - case 'project:maka': - case 'project:agents': - return 'project'; - case 'workspace:legacy': - return 'workspace'; - case 'user:maka': - case 'user:agents': - return 'user'; - default: - return undefined; - } -} - export async function listSkillLocations( context: SkillLocationContext, ): Promise { @@ -67,14 +52,13 @@ export async function listSkillLocations( ); const scan = await scanSkillsWithDiagnostics(discovery); const locations = await Promise.all(discovery.entries.map(async (entry) => { - const ref = skillLocationRef(entry); const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); const diagnostic = scan.discoveryDiagnostics.find(({ path }) => path === entry.dir); return { - ref, - scope: entry.scope as SkillLocation['scope'], - source: entry.source as SkillLocation['source'], - path: entry.dir, + ref: entry.refPrefix, + scope: entry.scope, + source: entry.source, + path: 'path' in inspected ? inspected.path : entry.dir, status: diagnostic?.reason ?? inspected.status, }; })); @@ -86,9 +70,9 @@ export async function resolveSkillLocation( ref: string, createIfMissing: boolean, ): Promise { - const scope = skillLocationScope(ref); - if (!scope) return { ok: false, reason: 'unknown_location' }; - if (scope === 'project' && context.projectRoot === null) { + const location = findSkillLocation(ref); + if (!location) return { ok: false, reason: 'unknown_location' }; + if (location.scope === 'project' && context.projectRoot === null) { return { ok: false, reason: 'read_failed' }; } const discovery = resolveSkillDiscoveryPaths( @@ -96,7 +80,7 @@ export async function resolveSkillLocation( context.workspaceRoot, context.homeDirectory ?? homedir(), ); - const entry = discovery.entries.find((candidate) => skillLocationRef(candidate) === ref); + const entry = discovery.entries.find((candidate) => candidate.refPrefix === ref); if (!entry) return { ok: false, reason: 'unknown_location' }; const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); @@ -112,18 +96,12 @@ export async function resolveSkillLocation( } } -function skillLocationRef( - entry: Pick, -): SkillLocationRef { - return `${entry.scope}:${entry.source}` as SkillLocationRef; -} - async function inspectDirectory( containmentRoot: string, target: string, ): Promise< - | { readonly status: 'available'; readonly path: string } - | { readonly status: 'missing' | 'blocked_path' | 'read_failed' } + | { readonly status: 'available' | 'missing'; readonly path: string } + | { readonly status: 'blocked_path' | 'read_failed' } > { if (!isPathInside(resolve(containmentRoot), resolve(target))) { return { status: 'blocked_path' }; @@ -146,7 +124,7 @@ async function inspectDirectory( try { const targetReal = await realpathAllowMissing(target); return isPathInside(rootReal, targetReal) - ? { status: 'missing' } + ? { status: 'missing', path: targetReal } : { status: 'blocked_path' }; } catch { return { status: 'read_failed' }; diff --git a/apps/desktop/src/preload/bridge-contract.d.ts b/apps/desktop/src/preload/bridge-contract.d.ts index 3c4ce60024..bfbd97ee0a 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -1870,7 +1870,7 @@ export interface MakaBridge { projectGit: { isGitRepo: boolean; branch?: string }; }>; openPath( - key: 'workspace' | 'skills' | 'memory' | 'project', + key: 'workspace' | 'memory' | 'project', sessionId?: string, host?: DesktopRuntimeHostRef, ): Promise< diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index bd943b8d48..f70bbfc4ca 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3724,7 +3724,7 @@ const makaBridge = { return invokeSessionRuntimeHost('app:sessionProjectInfo', sessionId); }, openPath( - key: 'workspace' | 'skills' | 'memory' | 'project', + key: 'workspace' | 'memory' | 'project', sessionId?: string, host?: DesktopRuntimeHostRef, ): Promise< diff --git a/apps/desktop/src/renderer/app-shell-copy.ts b/apps/desktop/src/renderer/app-shell-copy.ts index 783eec74d4..4f84cc939c 100644 --- a/apps/desktop/src/renderer/app-shell-copy.ts +++ b/apps/desktop/src/renderer/app-shell-copy.ts @@ -44,7 +44,7 @@ export function commandPaletteActionErrorMessage( export function openPathActionErrorMessage( error: unknown, - key: 'workspace' | 'project' | 'skills', + key: 'workspace' | 'project', locale: UiLocale, ): string { const copy = getShellCopy(locale); diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index 8906a5ef44..dfcc099076 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -290,22 +290,26 @@ export function useSkillsController( async (options: RefreshOptions = {}): Promise => { const generation = ++generationsRef.current.skillLocations; setSkillLocationSnapshot(null); + const isCurrent = () => + mountedRef.current && + inputRef.current.clientPathsAccessible && + generation === generationsRef.current.skillLocations; + if (!isCurrent()) return; const copy = getShellCopy(inputRef.current.uiLocale).skillActions; try { const next = await runOnDefaultRuntimeHost( services.runtimeHosts, - (host) => services.skills.listLocations(host), + async (host) => isCurrent() ? services.skills.listLocations(host) : null, ); + if (!next.value || !isCurrent()) return; + const snapshot = next.value; await runIfDefaultRuntimeHostCurrent( services.runtimeHosts, next.host, () => { - if ( - mountedRef.current && - generation === generationsRef.current.skillLocations - ) { + if (isCurrent()) { setSkillLocationSnapshot({ - ...next.value, + ...snapshot, host: next.host, generation, }); @@ -313,18 +317,9 @@ export function useSkillsController( }, ); } catch (error) { - if ( - !mountedRef.current || - generation !== generationsRef.current.skillLocations - ) { - return; - } + if (!isCurrent()) return; const shouldReport = await shouldReportRefreshError(options, error); - if ( - mountedRef.current && - generation === generationsRef.current.skillLocations && - shouldReport - ) { + if (isCurrent() && shouldReport) { reportRuntimeHostError( copy.refreshLocationsFailedTitle, copy.refreshLocationsFallback, @@ -890,6 +885,9 @@ export function useSkillsController( undefined, next.diagnosticTarget, ); + if (next.value.reason === 'stale_context' || next.value.reason === 'missing') { + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); + } } return; } diff --git a/apps/desktop/src/renderer/locales/shell-copy.ts b/apps/desktop/src/renderer/locales/shell-copy.ts index 1ee4b8562a..f5a2aced2c 100644 --- a/apps/desktop/src/renderer/locales/shell-copy.ts +++ b/apps/desktop/src/renderer/locales/shell-copy.ts @@ -156,7 +156,7 @@ type ShellCopy = { actions: { retry: string; }; - paths: Record<'workspace' | 'project' | 'skills', string>; + paths: Record<'workspace' | 'project', string>; errors: { messageRead: string; messageRefresh: string; @@ -210,7 +210,7 @@ type ShellCopy = { remoteDirectoryHideHidden: string; runtimeHostReadiness: Record<'connecting' | 'reconnecting' | 'unavailable', string>; openFailedTitle(path: string): string; - openPathLabels: Record<'workspace' | 'skills' | 'memory' | 'project', string>; + openPathLabels: Record<'workspace' | 'memory' | 'project', string>; openPathFailures: Record< 'unknown-key' | 'not-allowed' | 'missing' | 'not-a-directory' | 'open-failed' | 'unknown', string @@ -761,7 +761,6 @@ const SHELL_COPY_BY_LOCALE = { paths: { workspace: '工作区文件夹', project: '项目目录', - skills: 'Skills 文件夹', }, errors: { messageRead: '任务内容暂时无法读取,请稍后重试。', @@ -821,7 +820,6 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: (path: string) => `无法打开${path}`, openPathLabels: { workspace: '工作区目录', - skills: 'Skills 目录', memory: '记忆目录', project: '项目目录', }, @@ -951,7 +949,7 @@ const SHELL_COPY_BY_LOCALE = { openLocationFallback: '无法打开技能位置,请稍后重试。', openLocationFailures: { unknown_location: '这个技能位置无效。', - stale_context: '项目已切换,请刷新技能位置后重试。', + stale_context: '技能位置已变化,请重试。', missing: '目录不存在。', blocked_path: '技能位置不在允许范围内,已阻止打开。', read_failed: '无法读取技能目录,请检查文件权限。', @@ -1281,7 +1279,6 @@ const SHELL_COPY_BY_LOCALE = { paths: { workspace: '工作區資料夾', project: '專案目錄', - skills: 'Skills 資料夾', }, errors: { messageRead: '任務內容暫時無法讀取,請稍後重試。', @@ -1341,7 +1338,6 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: (path: string) => `無法開啟${path}`, openPathLabels: { workspace: '工作區目錄', - skills: 'Skills 目錄', memory: '記憶目錄', project: '專案目錄', }, @@ -1471,7 +1467,7 @@ const SHELL_COPY_BY_LOCALE = { openLocationFallback: '無法開啟技能位置,請稍後重試。', openLocationFailures: { unknown_location: '這個技能位置無效。', - stale_context: '專案已切換,請重新整理技能位置後再試。', + stale_context: '技能位置已變更,請再試一次。', missing: '目錄不存在。', blocked_path: '技能位置不在允許範圍內,已阻止開啟。', read_failed: '無法讀取技能目錄,請檢查檔案權限。', @@ -1801,7 +1797,6 @@ const SHELL_COPY_BY_LOCALE = { paths: { workspace: 'workspace', project: 'project folder', - skills: 'Skills folder', }, errors: { messageRead: 'Task content is temporarily unavailable. Try again later.', @@ -1863,7 +1858,6 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: (path: string) => `Could not open ${path}`, openPathLabels: { workspace: 'workspace folder', - skills: 'Skills folder', memory: 'memory folder', project: 'project folder', }, @@ -1996,7 +1990,7 @@ const SHELL_COPY_BY_LOCALE = { openLocationFallback: 'The Skill location could not be opened. Try again later.', openLocationFailures: { unknown_location: 'This Skill location is invalid.', - stale_context: 'The project has changed. Refresh Skill locations and try again.', + stale_context: 'Skill locations have changed. Try again.', missing: 'The folder does not exist.', blocked_path: 'The Skill location is outside the allowed paths, so opening was blocked.', read_failed: 'The Skill folder could not be read. Check file permissions.', diff --git a/apps/desktop/src/renderer/open-path.ts b/apps/desktop/src/renderer/open-path.ts index 59c99e2d76..890fbbcf73 100644 --- a/apps/desktop/src/renderer/open-path.ts +++ b/apps/desktop/src/renderer/open-path.ts @@ -29,7 +29,7 @@ import type { UiLocale } from '@maka/core/ui-locale'; import { getShellCopy } from './locales/shell-copy.js'; -export type OpenPathKey = 'workspace' | 'skills' | 'memory' | 'project'; +export type OpenPathKey = 'workspace' | 'memory' | 'project'; export type OpenPathFailureReason = 'unknown-key' | 'not-allowed' | 'missing' | 'not-a-directory' | 'open-failed'; diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 0634265d02..34654f5397 100644 --- a/docs/windows-test-inventory.md +++ b/docs/windows-test-inventory.md @@ -17,9 +17,9 @@ Locations intentionally omit line numbers so unrelated edits do not invalidate t |---|---:| | windows-backend-gap | 27 | | portable-candidate | 35 | -| platform-contract | 37 | +| platform-contract | 38 | -Total Windows-excluded declarations: **99** +Total Windows-excluded declarations: **100** ## Inventory @@ -31,6 +31,7 @@ Total Windows-excluded declarations: **99** | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP published write explicitly reports out-of-sync when reconciliation ${phase} fails | `process.platform === 'win32'` | | portable-candidate | `apps/desktop/src/main/__tests__/mcp-ipc-commit-unknown.test.ts` MCP cancelled install does not start a new connection during post-rename reconciliation | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/project-context-root.test.ts` rejects a session cwd without read and traversal access | `process.platform === 'win32' ? 'POSIX permissions are required to make the session cwd inaccessible' : process.getuid?.() === 0` | +| platform-contract | `apps/desktop/src/main/__tests__/runtime-host-skills-ipc-main.test.ts` reports create_failed without opening when a Skill directory parent is not writable | `process.platform === 'win32' ? 'POSIX permissions are required to make the Skill directory parent read-only' : process.getuid?.() === 0` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` imports the login PATH without importing application control variables | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` keeps the inherited PATH and does not log shell stderr when capture fails | `process.platform === 'win32'` | | platform-contract | `apps/desktop/src/main/__tests__/shell-env.test.ts` kills login-shell descendants when capture times out | `process.platform === 'win32'` | diff --git a/packages/core/package.json b/packages/core/package.json index cade46788e..270a268b3f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -63,6 +63,7 @@ "./artifacts": "./dist/artifacts.js", "./pet": "./dist/pet.js", "./skill-invocation": "./dist/skill-invocation.js", + "./skill-locations": "./dist/skill-locations.js", "./runtime-inputs": "./dist/runtime-inputs.js", "./e2e-fixture": "./dist/e2e-fixture.js", "./capabilities": "./dist/capabilities.js", diff --git a/packages/core/src/skill-locations.ts b/packages/core/src/skill-locations.ts new file mode 100644 index 0000000000..6630383766 --- /dev/null +++ b/packages/core/src/skill-locations.ts @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** Standard discovery locations in precedence order; also the directory-opening allowlist. */ +export const STANDARD_SKILL_LOCATIONS = [ + { ref: 'project:maka', scope: 'project', source: 'maka', segments: ['.maka', 'skills'] }, + { ref: 'project:agents', scope: 'project', source: 'agents', segments: ['.agents', 'skills'] }, + { ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', segments: ['skills'] }, + { ref: 'user:maka', scope: 'user', source: 'maka', segments: ['.maka', 'skills'] }, + { ref: 'user:agents', scope: 'user', source: 'agents', segments: ['.agents', 'skills'] }, +] as const; + +export type SkillLocationDefinition = (typeof STANDARD_SKILL_LOCATIONS)[number]; +export type SkillLocationRef = SkillLocationDefinition['ref']; + +export function findSkillLocation(ref: string): SkillLocationDefinition | undefined { + return STANDARD_SKILL_LOCATIONS.find((location) => location.ref === ref); +} diff --git a/packages/runtime/src/__tests__/skills.test.ts b/packages/runtime/src/__tests__/skills.test.ts index 8410e473d8..a17ed530d7 100644 --- a/packages/runtime/src/__tests__/skills.test.ts +++ b/packages/runtime/src/__tests__/skills.test.ts @@ -1064,25 +1064,40 @@ Body.`, '/home/user', ); assert.deepEqual(entries, [ - { dir: '/repo/.maka/skills', containmentRoot: '/repo', scope: 'project', source: 'maka' }, - { dir: '/repo/.agents/skills', containmentRoot: '/repo', scope: 'project', source: 'agents' }, + { + dir: '/repo/.maka/skills', + containmentRoot: '/repo', + scope: 'project', + source: 'maka', + refPrefix: 'project:maka', + }, + { + dir: '/repo/.agents/skills', + containmentRoot: '/repo', + scope: 'project', + source: 'agents', + refPrefix: 'project:agents', + }, { dir: '/workspace/skills', containmentRoot: '/workspace', scope: 'workspace', source: 'legacy', + refPrefix: 'workspace:legacy', }, { dir: '/home/user/.maka/skills', containmentRoot: '/home/user', scope: 'user', source: 'maka', + refPrefix: 'user:maka', }, { dir: '/home/user/.agents/skills', containmentRoot: '/home/user', scope: 'user', source: 'agents', + refPrefix: 'user:agents', }, ]); assert.deepEqual(dirs, [ diff --git a/packages/runtime/src/skills-discovery.ts b/packages/runtime/src/skills-discovery.ts index f84267a4c6..132ef3eafb 100644 --- a/packages/runtime/src/skills-discovery.ts +++ b/packages/runtime/src/skills-discovery.ts @@ -21,6 +21,11 @@ import { createHash } from 'node:crypto'; import { homedir } from 'node:os'; import { lstat, readdir, realpath, stat } from 'node:fs/promises'; import { join } from 'node:path'; +import { + STANDARD_SKILL_LOCATIONS, + type SkillLocationDefinition, + type SkillLocationRef, +} from '@maka/core/skill-locations'; import { isPathInside, readContainedRegularFile } from './path-containment.js'; import { validateSkillMetadata } from './skills-metadata.js'; import type { SkillValidationIssue } from './skills-metadata.js'; @@ -55,6 +60,12 @@ export interface SkillDiscoveryEntry { refPrefix?: string; } +interface StandardSkillDiscoveryEntry extends SkillDiscoveryEntry { + scope: SkillLocationDefinition['scope']; + source: SkillLocationDefinition['source']; + refPrefix: SkillLocationRef; +} + export type SkillSource = | string | { dirs: string[]; stateRoot: string; entries?: SkillDiscoveryEntry[] }; @@ -181,39 +192,22 @@ export function resolveSkillDiscoveryPaths( cwd: string | null, workspaceRoot: string, homeDir?: string, -): { entries: SkillDiscoveryEntry[]; dirs: string[]; stateRoot: string } { - const home = homeDir ?? homedir(); - const entries: SkillDiscoveryEntry[] = [ - ...(cwd === null +): { entries: StandardSkillDiscoveryEntry[]; dirs: string[]; stateRoot: string } { + const roots = { project: cwd, workspace: workspaceRoot, user: homeDir ?? homedir() }; + const entries = STANDARD_SKILL_LOCATIONS.flatMap((location): StandardSkillDiscoveryEntry[] => { + const root = roots[location.scope]; + return root === null ? [] : [ { - dir: join(cwd, '.maka', 'skills'), - containmentRoot: cwd, - scope: 'project' as const, - source: 'maka' as const, + dir: join(root, ...location.segments), + containmentRoot: root, + scope: location.scope, + source: location.source, + refPrefix: location.ref, }, - { - dir: join(cwd, '.agents', 'skills'), - containmentRoot: cwd, - scope: 'project' as const, - source: 'agents' as const, - }, - ]), - { - dir: join(workspaceRoot, 'skills'), - containmentRoot: workspaceRoot, - scope: 'workspace', - source: 'legacy', - }, - { dir: join(home, '.maka', 'skills'), containmentRoot: home, scope: 'user', source: 'maka' }, - { - dir: join(home, '.agents', 'skills'), - containmentRoot: home, - scope: 'user', - source: 'agents', - }, - ]; + ]; + }); return { entries, dirs: entries.map((e) => e.dir), stateRoot: workspaceRoot }; } diff --git a/packages/ui/src/__tests__/skills-panel-locations.test.tsx b/packages/ui/src/__tests__/skills-panel-locations.test.tsx index 0b633bd48d..25a96b6d93 100644 --- a/packages/ui/src/__tests__/skills-panel-locations.test.tsx +++ b/packages/ui/src/__tests__/skills-panel-locations.test.tsx @@ -55,8 +55,18 @@ const locations: SkillLocation[] = [ { ref: 'user:agents', scope: 'user', source: 'agents', path: '/home/user/.agents/skills', status: 'available', skillCount: 1 }, ]; -test('Skill locations expose all supported paths and create only a missing directory', async () => { +test('Skill locations close the menu before opening and create only a missing directory', async () => { const { document, window } = parseHTML('
'); + const frames = new Map(); + let frameId = 0; + async function flushFrames(): Promise { + await act(async () => { + const pending = [...frames.values()]; + frames.clear(); + for (const callback of pending) callback(0); + await Promise.resolve(); + }); + } window.getComputedStyle = () => ({ direction: 'ltr', writingMode: 'horizontal-tb', @@ -67,10 +77,10 @@ test('Skill locations expose all supported paths and create only a missing direc window, matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), requestAnimationFrame: (callback: FrameRequestCallback) => { - callback(0); - return 1; + frames.set(++frameId, callback); + return frameId; }, - cancelAnimationFrame() {}, + cancelAnimationFrame: (id: number) => frames.delete(id), IS_REACT_ACT_ENVIRONMENT: true, }); @@ -105,7 +115,21 @@ test('Skill locations expose all supported paths and create only a missing direc assert.match(menuItem(document, 'User · Maka').outerHTML, /aria-disabled="true"/); await clickMenuItem(document, window, 'Project · Maka'); + assert.deepEqual(opened, []); + assert.equal(document.querySelector('[aria-label="More Skill actions"]')?.getAttribute('aria-expanded'), 'false'); + await flushFrames(); assert.deepEqual(opened, [{ ref: 'project:maka', createIfMissing: true }]); + + await clickByLabel(document, window, 'More Skill actions'); + await clickMenuItem(document, window, 'Skill locations…'); + await clickMenuItem(document, window, 'Project · Agents'); + assert.equal(opened.length, 1); + assert.equal(document.querySelector('[aria-label="More Skill actions"]')?.getAttribute('aria-expanded'), 'false'); + await flushFrames(); + assert.deepEqual(opened, [ + { ref: 'project:maka', createIfMissing: true }, + { ref: 'project:agents', createIfMissing: false }, + ]); }); async function clickByLabel( diff --git a/packages/ui/src/module-panel-types.ts b/packages/ui/src/module-panel-types.ts index 2672098943..bc37af4c66 100644 --- a/packages/ui/src/module-panel-types.ts +++ b/packages/ui/src/module-panel-types.ts @@ -25,6 +25,9 @@ import type { } from '@maka/core/daily-review'; import type { CreateScheduledTaskInput, ScheduledTaskEffect, UpdateScheduledTaskInput } from '@maka/core/scheduled-task'; +import type { SkillLocationDefinition, SkillLocationRef } from '@maka/core/skill-locations'; + +export type { SkillLocationRef } from '@maka/core/skill-locations'; export interface SkillEntry { kind?: 'skill' | 'discovery_diagnostic'; @@ -62,17 +65,10 @@ export interface SkillEntry { manageable?: boolean; } -export type SkillLocationRef = - | 'project:maka' - | 'project:agents' - | 'workspace:legacy' - | 'user:maka' - | 'user:agents'; - export interface SkillLocation { ref: SkillLocationRef; - scope: 'project' | 'workspace' | 'user'; - source: 'maka' | 'agents' | 'legacy'; + scope: SkillLocationDefinition['scope']; + source: SkillLocationDefinition['source']; path: string; status: 'available' | 'missing' | 'blocked_path' | 'read_failed'; skillCount: number; diff --git a/packages/ui/src/skills-panel.tsx b/packages/ui/src/skills-panel.tsx index 4be3ecc5fc..1a263463b7 100644 --- a/packages/ui/src/skills-panel.tsx +++ b/packages/ui/src/skills-panel.tsx @@ -678,7 +678,7 @@ export function SkillsModuleMain(props: { description={location.path} endContent={endContent} isDisabled={disabled} - onClick={() => void runSkillAction( + onClick={() => runPageActionAfterMenuClose( `location:${location.ref}`, () => props.onOpenSkillLocation?.( location.ref, From 00b30eaebb11f301e78d2558ea23fde78af9d42c Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 18 Sep 2026 22:05:36 +0800 Subject: [PATCH 6/7] fix(desktop): refresh Skill locations when local paths become available Preserve the deferred startup refresh while loading locations when asynchronous Project capabilities arrive. Clear stale location snapshots on capability revocation and refresh on restoration without reloading the other Skill projections. Cover capability readiness before and after the startup frame through the Module Hub controller lifecycle. Generated-by: Codex --- .../__tests__/module-hub-provider.test.ts | 80 +++++++++++++++++++ .../module-hub-skills-controller.test.ts | 1 - .../controller/use-skills-controller.ts | 7 ++ 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts index a56e5aa7fa..50baa87423 100644 --- a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts @@ -296,3 +296,83 @@ test('command port keeps the newest controller through stale cleanup', async () await port.copyTodayDailyReview(); assert.deepEqual(calls, ['second:refresh', 'second:create']); }); + +for (const capabilityTiming of ['before', 'after'] as const) { + test(`Skills locations load when local path capability arrives ${capabilityTiming} deferred startup`, async () => { + const { root } = installReactRenderer(); + const frames: FrameRequestCallback[] = []; + globalThis.requestAnimationFrame = (callback) => frames.push(callback); + let current: ReturnType | undefined; + let locationReads = 0; + let skillReads = 0; + function skills() { + assert.ok(current); + return current.host.skills; + } + const defaults = createFakeModuleHubServices(); + const services = createFakeModuleHubServices({ + skills: { + ...defaults.skills, + list: async () => { skillReads += 1; return []; }, + listLocations: async () => { + locationReads += 1; + return { + contextIds: { workspace: `workspace-${locationReads}` }, + locations: [{ + ref: 'workspace:legacy', scope: 'workspace', source: 'legacy', + path: `/workspace-${locationReads}/skills`, status: 'available', skillCount: 0, + }], + }; + }, + }, + }); + function Probe({ clientPathsAccessible }: { clientPathsAccessible: boolean }) { + current = useModuleHubController({ + selection: { section: 'extensions', module: 'skills' }, + selectModule: () => undefined, + clientPathsAccessible, + useSkillInChat: () => undefined, + openSession: () => undefined, + appendComposerText: () => undefined, + captureActiveComposerClaim: () => undefined, + }); + return null; + } + async function render(clientPathsAccessible: boolean): Promise { + await act(async () => root.render(createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { + children: createElement(ModuleHubServicesProvider, { services }, + createElement(Probe, { clientPathsAccessible })), + }), + }))); + } + + await render(false); + assert.equal(skillReads, 0); + assert.equal(locationReads, 0); + if (capabilityTiming === 'before') { + await render(true); + assert.equal(skillReads, 0); + assert.equal(locationReads, 0); + } + await act(async () => { for (const frame of frames.splice(0)) frame(0); }); + assert.equal(skillReads, 1); + if (capabilityTiming === 'after') { + assert.equal(locationReads, 0); + assert.deepEqual(skills().skillLocations, []); + await render(true); + } + assert.equal(locationReads, 1); + assert.equal(skills().skillLocations[0]?.path, '/workspace-1/skills'); + assert.equal(typeof skills().onOpenSkillLocation, 'function'); + await render(false); + assert.equal(locationReads, 1); + assert.deepEqual(skills().skillLocations, []); + assert.equal(skills().onOpenSkillLocation, undefined); + await render(true); + assert.equal(locationReads, 2); + assert.equal(skills().skillLocations[0]?.path, '/workspace-2/skills'); + assert.equal(skillReads, 1); + }); +} diff --git a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts index 9b5123c69b..c56ab7d9de 100644 --- a/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-skills-controller.test.ts @@ -725,7 +725,6 @@ test('remote Skills refreshes skip location IPC and clear local directory action assert.equal(controller().host.onOpenSkillLocation, undefined); await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); - await act(async () => controller().refreshProjectSkills()); assert.equal(locationReads, 1); assert.equal(controller().host.skillLocations[0]?.path, '/local-project/.agents/skills'); await act(async () => renderController(root, services, input(records))); diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index dfcc099076..17f695e71a 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -336,6 +336,13 @@ export function useSkillsController( ], ); + useEffect(() => { + // Keep startup deferred to the parent lifecycle, but catch capability changes + // after its first refresh (Project capabilities can arrive after that frame). + if (generationsRef.current.skillLocations === 0) return; + void refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); + }, [input.clientPathsAccessible, isSkillsSurfaceActive, refreshSkillLocations]); + const refreshBundledSkillCatalog = useCallback( async (options: RefreshOptions = {}): Promise => { const generation = ++generationsRef.current.bundledSkillCatalog; From 443605d70cb7bde60e7fd84799669b0c91187085 Mon Sep 17 00:00:00 2001 From: liuxiaocs7 Date: Fri, 18 Sep 2026 22:12:09 +0800 Subject: [PATCH 7/7] fix(desktop): preserve deferred Skill reads in StrictMode Track whether locations were requested independently of the generation fence, which also advances during StrictMode cleanup. Cover at-mount and asynchronous capability readiness under the real StrictMode lifecycle. Generated-by: Codex --- .../__tests__/module-hub-provider.test.ts | 25 +++++++++++++------ .../controller/use-skills-controller.ts | 4 ++- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts index 50baa87423..8a6b750a00 100644 --- a/apps/desktop/src/main/__tests__/module-hub-provider.test.ts +++ b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts @@ -19,7 +19,7 @@ import assert from 'node:assert/strict'; import { afterEach, test } from 'node:test'; -import { act, createElement, Fragment } from 'react'; +import { act, createElement, Fragment, StrictMode } from 'react'; import type { ScheduledTask } from '@maka/core/scheduled-task'; import { LocaleProvider, ToastProvider } from '@maka/ui'; import { @@ -297,11 +297,16 @@ test('command port keeps the newest controller through stale cleanup', async () assert.deepEqual(calls, ['second:refresh', 'second:create']); }); -for (const capabilityTiming of ['before', 'after'] as const) { +for (const capabilityTiming of ['at mount', 'before', 'after'] as const) { test(`Skills locations load when local path capability arrives ${capabilityTiming} deferred startup`, async () => { const { root } = installReactRenderer(); - const frames: FrameRequestCallback[] = []; - globalThis.requestAnimationFrame = (callback) => frames.push(callback); + const frames = new Map(); + let frameId = 0; + globalThis.requestAnimationFrame = (callback) => { + frames.set(++frameId, callback); + return frameId; + }; + globalThis.cancelAnimationFrame = (id) => { frames.delete(id); }; let current: ReturnType | undefined; let locationReads = 0; let skillReads = 0; @@ -339,16 +344,16 @@ for (const capabilityTiming of ['before', 'after'] as const) { return null; } async function render(clientPathsAccessible: boolean): Promise { - await act(async () => root.render(createElement(LocaleProvider, { + await act(async () => root.render(createElement(StrictMode, null, createElement(LocaleProvider, { locale: 'en', children: createElement(ToastProvider, { children: createElement(ModuleHubServicesProvider, { services }, createElement(Probe, { clientPathsAccessible })), }), - }))); + })))); } - await render(false); + await render(capabilityTiming === 'at mount'); assert.equal(skillReads, 0); assert.equal(locationReads, 0); if (capabilityTiming === 'before') { @@ -356,7 +361,11 @@ for (const capabilityTiming of ['before', 'after'] as const) { assert.equal(skillReads, 0); assert.equal(locationReads, 0); } - await act(async () => { for (const frame of frames.splice(0)) frame(0); }); + await act(async () => { + const pending = [...frames.values()]; + frames.clear(); + for (const frame of pending) frame(0); + }); assert.equal(skillReads, 1); if (capabilityTiming === 'after') { assert.equal(locationReads, 0); diff --git a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts index 17f695e71a..b12c600825 100644 --- a/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts +++ b/apps/desktop/src/renderer/features/module-hub/controller/use-skills-controller.ts @@ -130,6 +130,7 @@ export function useSkillsController( managedSkillSources: 0, bundledSkillCatalog: 0, }); + const skillLocationsRequestedRef = useRef(false); const mountedRef = useRef(true); const inputRef = useRef(input); inputRef.current = input; @@ -288,6 +289,7 @@ export function useSkillsController( const refreshSkillLocations = useCallback( async (options: RefreshOptions = {}): Promise => { + skillLocationsRequestedRef.current = true; const generation = ++generationsRef.current.skillLocations; setSkillLocationSnapshot(null); const isCurrent = () => @@ -339,7 +341,7 @@ export function useSkillsController( useEffect(() => { // Keep startup deferred to the parent lifecycle, but catch capability changes // after its first refresh (Project capabilities can arrive after that frame). - if (generationsRef.current.skillLocations === 0) return; + if (!skillLocationsRequestedRef.current) return; void refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); }, [input.clientPathsAccessible, isSkillsSurfaceActive, refreshSkillLocations]);