diff --git a/apps/desktop/renderer-architecture.json b/apps/desktop/renderer-architecture.json index 63b17192dd..5b1b54c1f7 100644 --- a/apps/desktop/renderer-architecture.json +++ b/apps/desktop/renderer-architecture.json @@ -389,7 +389,7 @@ "react": 1 }, "importSpecifiers": 9, - "nonTriviaTokens": 2299 + "nonTriviaTokens": 2276 }, "src/renderer/app-shell-context-compaction.ts": { "importDeclarations": 0, @@ -419,7 +419,7 @@ "@maka/core/redaction": 1 }, "importSpecifiers": 1, - "nonTriviaTokens": 393 + "nonTriviaTokens": 391 }, "src/renderer/app-shell-detail-panel.tsx": { "importDeclarations": 0, @@ -538,7 +538,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, @@ -563,7 +563,7 @@ "./session-workspace-errors": 1 }, "importSpecifiers": 7, - "nonTriviaTokens": 2284 + "nonTriviaTokens": 2157 }, "src/renderer/app-shell-revision-actions.ts": { "importDeclarations": 4, @@ -862,7 +862,7 @@ "react": 1 }, "importSpecifiers": 100, - "nonTriviaTokens": 13135 + "nonTriviaTokens": 13127 }, "src/renderer/use-app-shell-composer-quotes.ts": { "importDeclarations": 0, 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__/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-provider.test.ts b/apps/desktop/src/main/__tests__/module-hub-provider.test.ts index a743e86c9f..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 { @@ -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, @@ -295,3 +296,92 @@ 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 ['at mount', 'before', 'after'] as const) { + test(`Skills locations load when local path capability arrives ${capabilityTiming} deferred startup`, async () => { + const { root } = installReactRenderer(); + 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; + 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(StrictMode, null, createElement(LocaleProvider, { + locale: 'en', + children: createElement(ToastProvider, { + children: createElement(ModuleHubServicesProvider, { services }, + createElement(Probe, { clientPathsAccessible })), + }), + })))); + } + + await render(capabilityTiming === 'at mount'); + 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 () => { + const pending = [...frames.values()]; + frames.clear(); + for (const frame of pending) 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-services-adapter.test.ts b/apps/desktop/src/main/__tests__/module-hub-services-adapter.test.ts index 3a7b8712d2..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 @@ -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', { contextId: 'project-context', 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', { 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 6a31e8150e..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 @@ -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 { OpenSkillLocationResult, 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 { + contextIds: { project }, + locations: [{ + ref: "project:agents", + scope: "project", + source: "agents", + path: `/${project}/.agents/skills`, + status: "missing", + skillCount: 0, + }], + }; +} + type ToastRecord = { kind: "success" | "error"; title: string; @@ -104,6 +119,7 @@ function input( return { uiLocale: "en", active: true, + clientPathsAccessible: false, toastApi: toastRecorder(records), useSkillInChat: () => undefined, ...overrides, @@ -147,6 +163,7 @@ test("Skills projections have independent same-Host generation and default-Host sourceType: "local", }, ], + listLocations: async () => skillLocations("project"), listBundledCatalog: async () => [ { id: "bundled-a", @@ -160,8 +177,9 @@ test("Skills projections have independent same-Host generation and default-Host }, }); - await act(async () => renderController(root, services, input(records))); - const first = controller().host.onRefreshSkills(); + 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()); assert.deepEqual( controller().host.skills.map(({ id }) => id), @@ -183,10 +201,12 @@ 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; - 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")]); @@ -213,6 +233,10 @@ test("Skills mutations preserve refresh combinations and suppress inactive or ca calls.push("list"); return []; }, + listLocations: async () => { + calls.push("locations"); + return { contextIds: {}, locations: [] }; + }, listManagedSources: async () => { calls.push("sources"); return []; @@ -237,7 +261,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 +285,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 +305,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 +333,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 +343,11 @@ 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 }; + }, }, }); @@ -332,7 +361,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 +372,7 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = services, input(records, { useSkillInChat: () => undefined, - openSkillsFolder: () => { - opened.push("folder"); - }, + clientPathsAccessible: true, }), ), ); @@ -354,8 +381,13 @@ test("Skills capabilities and stale mutation diagnostics are fenced", async () = typeof controller().host.onImportManagedSkillSource, "function", ); - controller().host.onOpenSkillsFolder?.(); - assert.deepEqual(opened, ["folder"]); + 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?.("project:agents", true), + ); + assert.deepEqual(opened, [{ ref: "project:agents", createIfMissing: true }]); const pendingOpen = controller().host.onOpenSkill?.("skill-a"); defaultHost = hostB; @@ -395,7 +427,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 +455,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,16 +475,23 @@ 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 + : { contextIds: {}, locations: [] }; + }, }, }); - await act(async () => renderController(root, services, input(records))); - const staleRefresh = controller().host.onRefreshSkills(); + await act(async () => renderController(root, services, input(records, { clientPathsAccessible: true }))); + let staleRefresh: Promise; await act(async () => { + staleRefresh = controller().host.onRefreshSkills(); await Promise.resolve(); await Promise.resolve(); }); - assert.equal(hostReads, 2); + assert.equal(hostReads, 3); await act(async () => controller().host.onRefreshSkills()); assert.deepEqual( @@ -459,6 +500,314 @@ test("stale Skills refresh errors do not outlive a newer successful generation", ); staleHostRecheck.resolve(host); + staleLocationRead.resolve({ contextIds: {}, 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, []); +}); + +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 }, + ]); +}); + +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 }))); + 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 772556b560..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,8 +18,18 @@ */ import assert from "node:assert/strict"; +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"; +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 { 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"; import type { DesktopRuntimeHostClient } from "../runtime-host-client.js"; import { registerRuntimeHostSkillsIpc } from "../runtime-host-skills-ipc-main.js"; @@ -45,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 () => "", @@ -62,6 +73,10 @@ test("projects an empty Skill surface until a remote Project is selected", async assert.ok(handler, `missing ${channel} handler`); assert.deepEqual(await handler({} as never), []); } + assert.deepEqual(await handlers.get("skills:locations:list")?.({} as never), { + contextIds: {}, + locations: [], + }); }); test("binds new-session Skill discovery to its explicit Project", async () => { @@ -83,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" @@ -111,3 +127,432 @@ 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" }), + getSelectedProject: async () => { throw new Error('Remote Host has no Client Project path'); }, + 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); +}); + +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"); + 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"; + 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, + 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", + 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.contextIds.project, + 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.contextIds.project, + createIfMissing: true, + }), + { ok: true }, + ); + assert.deepEqual(opened, [await realpath(join(projectB, ".maka", "skills"))]); + } finally { + 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']); + 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' }); + + 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'))]); + 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/__tests__/skill-locations.test.ts b/apps/desktop/src/main/__tests__/skill-locations.test.ts new file mode 100644 index 0000000000..9a7014f846 --- /dev/null +++ b/apps/desktop/src/main/__tests__/skill-locations.test.ts @@ -0,0 +1,179 @@ +/* + * 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 { 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 () => { + 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('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' + ? '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'); + 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'); + 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/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-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 7ba9e40f22..958ffbc26a 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -1777,6 +1777,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 d019762473..4a03fd831a 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,10 @@ * under the License. */ +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'; @@ -36,7 +40,9 @@ 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, @@ -49,6 +55,12 @@ 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, + type SkillLocationContext, +} from "./skill-locations.js"; +import type { CurrentProjectSelection } from './project-root-controller.js'; import { handleReconnectableRead, type ReconnectableReadIpcMain, @@ -64,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; } @@ -93,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 []; @@ -181,6 +197,47 @@ export function registerRuntimeHostSkillsIpc( ); }); + handleReconnectableRead(deps.ipcMain, "skills:locations:list", async (): Promise => { + 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 { + contextIds: { project, workspace, user }, + locations, + }; + }); + + deps.ipcMain.handle( + "skills:locations:open", + 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 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); + if (!contextId || options?.contextId !== contextId) { + return { ok: false, reason: "stale_context" }; + } + const resolved = await resolveSkillLocation( + context, + 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"); @@ -348,6 +405,38 @@ export function registerRuntimeHostSkillsIpc( ); } +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([hostRegistration, scope, canonicalRoot, scope === 'project' ? context.projectId : null])) + .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 new file mode 100644 index 0000000000..11157ef462 --- /dev/null +++ b/apps/desktop/src/main/skill-locations.ts @@ -0,0 +1,169 @@ +/* + * 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, 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, +} from '@maka/runtime/skills'; +import type { SkillLocation } 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' | 'read_failed' | 'create_failed'; + }; + +export interface SkillLocationContext { + readonly projectRoot: string | null; + 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 locations = await Promise.all(discovery.entries.map(async (entry) => { + const inspected = await inspectDirectory(entry.containmentRoot, entry.dir); + const diagnostic = scan.discoveryDiagnostics.find(({ path }) => path === entry.dir); + return { + ref: entry.refPrefix, + scope: entry.scope, + source: entry.source, + path: 'path' in inspected ? inspected.path : entry.dir, + status: diagnostic?.reason ?? inspected.status, + }; + })); + return withSkillLocationCounts(locations, [...scan.inventory, ...scan.rejected]); +} + +export async function resolveSkillLocation( + context: SkillLocationContext, + ref: string, + createIfMissing: boolean, +): Promise { + 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( + context.projectRoot, + context.workspaceRoot, + context.homeDirectory ?? homedir(), + ); + 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); + 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: 'read_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' }; + } +} + +async function inspectDirectory( + containmentRoot: string, + target: string, +): Promise< + | { readonly status: 'available' | 'missing'; readonly path: string } + | { readonly status: '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', path: targetReal } + : { status: 'blocked_path' }; + } catch { + return { status: 'read_failed' }; + } + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + return { status: 'blocked_path' }; + } + try { + const targetReal = await realpath(target); + 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' }; + } +} + +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 cf4c446ccd..bb8edcf90a 100644 --- a/apps/desktop/src/preload/bridge-contract.d.ts +++ b/apps/desktop/src/preload/bridge-contract.d.ts @@ -263,7 +263,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 } 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 { @@ -1889,7 +1890,7 @@ export interface MakaBridge { projectGit: { isGitRepo: boolean; branch?: string }; }>; openPath( - key: 'workspace' | 'skills' | 'memory' | 'project', + key: 'workspace' | 'memory' | 'project', sessionId?: string, host?: DesktopRuntimeHostRef, ): Promise< @@ -1987,6 +1988,10 @@ export interface MakaBridge { | { ok: false; reason: 'cancelled' | 'invalid_skill' | 'already_exists' | 'blocked_path' | 'write_failed' } >; }; + locations: { + list(host?: DesktopRuntimeHostRef): Promise; + open(ref: SkillLocationRef, options: OpenSkillLocationOptions, host?: DesktopRuntimeHostRef): Promise; + }; 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 06301080e9..241e4b5298 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -3734,7 +3734,7 @@ const makaBridge = { return invokeSessionRuntimeHost('app:sessionProjectInfo', sessionId); }, openPath( - key: 'workspace' | 'skills' | 'memory' | 'project', + key: 'workspace' | 'memory' | 'project', sessionId?: string, host?: DesktopRuntimeHostRef, ): Promise< @@ -3927,6 +3927,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: import('../shared/skill-locations.js').OpenSkillLocationOptions, 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 322b42c777..3344c283e8 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-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/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 a1d809e558..20839b31d5 100644 --- a/apps/desktop/src/renderer/app-shell.tsx +++ b/apps/desktop/src/renderer/app-shell.tsx @@ -1136,7 +1136,6 @@ function AppShellContent({ restoreProject, openProjectFolder, openWorkspaceFolder, - openSkillsFolder, } = useAppShellProjectContext({ uiLocale, rendererMountedRef, @@ -2175,7 +2174,6 @@ function AppShellContent({ openSideConversation: () => commands.openTool('side-chat'), openSettings, openSettingsSection, - openSkillsFolder, openWorkspaceFolder, refreshConnections: defaultHostConnections.refreshConnections, copyTodayDailyReview: moduleHubCommands.copyTodayDailyReview, @@ -2212,7 +2210,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 96ae2ca7db..e2cd378682 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..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 @@ -24,8 +24,11 @@ import type { ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, + SkillLocation, + SkillLocationRef, ToastApi, } from "@maka/ui"; +import type { SkillLocationsSnapshot } from "../../../../shared/skill-locations.js"; import { getShellCopy, localizedShellErrorMessage, @@ -48,12 +51,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 +95,28 @@ 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. */ +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 [skillLocationSnapshot, setSkillLocationSnapshot] = + useState(null); const [revision, setRevision] = useState(0); const [managedSkillSources, setManagedSkillSources] = useState< ManagedSkillSourceEntry[] @@ -109,9 +126,11 @@ export function useSkillsController( >([]); const generationsRef = useRef>({ skills: 0, + skillLocations: 0, managedSkillSources: 0, bundledSkillCatalog: 0, }); + const skillLocationsRequestedRef = useRef(false); const mountedRef = useRef(true); const inputRef = useRef(input); inputRef.current = input; @@ -121,6 +140,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 +287,64 @@ export function useSkillsController( ], ); + const refreshSkillLocations = useCallback( + async (options: RefreshOptions = {}): Promise => { + skillLocationsRequestedRef.current = true; + 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, + async (host) => isCurrent() ? services.skills.listLocations(host) : null, + ); + if (!next.value || !isCurrent()) return; + const snapshot = next.value; + await runIfDefaultRuntimeHostCurrent( + services.runtimeHosts, + next.host, + () => { + if (isCurrent()) { + setSkillLocationSnapshot({ + ...snapshot, + host: next.host, + generation, + }); + } + }, + ); + } catch (error) { + if (!isCurrent()) return; + const shouldReport = await shouldReportRefreshError(options, error); + if (isCurrent() && shouldReport) { + reportRuntimeHostError( + copy.refreshLocationsFailedTitle, + copy.refreshLocationsFallback, + error, + ); + } + } + }, + [ + reportRuntimeHostError, + services.runtimeHosts, + services.skills, + shouldReportRefreshError, + ], + ); + + useEffect(() => { + // Keep startup deferred to the parent lifecycle, but catch capability changes + // after its first refresh (Project capabilities can arrive after that frame). + if (!skillLocationsRequestedRef.current) return; + void refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); + }, [input.clientPathsAccessible, isSkillsSurfaceActive, refreshSkillLocations]); + const refreshBundledSkillCatalog = useCallback( async (options: RefreshOptions = {}): Promise => { const generation = ++generationsRef.current.bundledSkillCatalog; @@ -412,6 +490,7 @@ export function useSkillsController( return; } await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); await refreshManagedSkillSources({ shouldShowError: isSkillsSurfaceActive, }); @@ -434,6 +513,7 @@ export function useSkillsController( [ isSkillsSurfaceActive, refreshManagedSkillSources, + refreshSkillLocations, refreshSkills, reportRuntimeHostError, services.runtimeHosts, @@ -463,6 +543,7 @@ export function useSkillsController( return; } await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); await refreshBundledSkillCatalog({ shouldShowError: isSkillsSurfaceActive, }); @@ -485,6 +566,7 @@ export function useSkillsController( [ isSkillsSurfaceActive, refreshBundledSkillCatalog, + refreshSkillLocations, refreshSkills, reportRuntimeHostError, services.runtimeHosts, @@ -707,6 +789,7 @@ export function useSkillsController( return; } await refreshSkills({ shouldShowError: isSkillsSurfaceActive }); + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); await refreshBundledSkillCatalog({ shouldShowError: isSkillsSurfaceActive, }); @@ -730,6 +813,7 @@ export function useSkillsController( [ isSkillsSurfaceActive, refreshBundledSkillCatalog, + refreshSkillLocations, refreshSkills, reportRuntimeHostError, services.runtimeHosts, @@ -775,25 +859,97 @@ export function useSkillsController( ], ); + const openSkillLocation = useCallback( + async ( + ref: SkillLocationRef, + createIfMissing: boolean, + ): Promise => { + 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 && + skillLocationSnapshot.generation === generationsRef.current.skillLocations; + if (!isCurrent()) return; + const copy = getShellCopy(inputRef.current.uiLocale).skillActions; + try { + const next = await runOnDefaultRuntimeHost( + services.runtimeHosts, + 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)) && isCurrent()) { + inputRef.current.toastApi.error( + copy.openLocationFailedTitle, + copy.openLocationFailures[next.value.reason], + undefined, + next.diagnosticTarget, + ); + if (next.value.reason === 'stale_context' || next.value.reason === 'missing') { + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); + } + } + return; + } + if (createIfMissing) { + await refreshSkillLocations({ shouldShowError: isSkillsSurfaceActive }); + } + } catch (error) { + if ((await shouldReportOperationError(error)) && isCurrent()) { + reportRuntimeHostError( + copy.openLocationFailedTitle, + copy.openLocationFallback, + error, + ); + } + } + }, + [ + isSkillsSurfaceActive, + refreshSkillLocations, + reportRuntimeHostError, + skillLocationSnapshot, + 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: skillLocationSnapshot?.locations ?? [], managedSkillSources, bundledSkillCatalog, - onRefreshSkills: refreshSkills, + onRefreshSkills: async () => { + await Promise.all([refreshSkills(), refreshSkillLocations()]); + }, onUseSkill: input.useSkillInChat, - ...(input.openSkillsFolder + ...(input.clientPathsAccessible ? { onOpenSkill: openSkill, - onOpenSkillsFolder: input.openSkillsFolder, + ...(skillLocationSnapshot?.locations.length + ? { onOpenSkillLocation: openSkillLocation } + : {}), onImportManagedSkillSource: importManagedSkillSource, } : {}), @@ -811,15 +967,18 @@ export function useSkillsController( bundledSkillCatalog, deleteSkill, importManagedSkillSource, - input.openSkillsFolder, + input.clientPathsAccessible, input.useSkillInChat, installBundledSkill, installManagedSkill, managedSkillSources, openSkill, + openSkillLocation, previewManagedSkillUpdate, refreshBundledSkillCatalog, + refreshSkillLocations, refreshManagedSkillSources, + 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 7abb633628..f8609a2dba 100644 --- a/apps/desktop/src/renderer/features/module-hub/ports.ts +++ b/apps/desktop/src/renderer/features/module-hub/ports.ts @@ -34,7 +34,13 @@ import type { ManagedSkillSourceEntry, ManagedSkillUpdatePreview, SkillEntry, + SkillLocationRef, } from '@maka/ui'; +import type { + OpenSkillLocationOptions, + OpenSkillLocationResult, + SkillLocationsSnapshot, +} from '../../../shared/skill-locations.js'; export type ModuleHubUnsubscribe = () => void; @@ -131,6 +137,7 @@ export type OpenSkillResult = 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 +172,11 @@ export interface ModuleHubSkillsService { target: 'file' | 'directory', host: ModuleHubRuntimeHostRef, ): Promise; + openLocation( + ref: SkillLocationRef, + options: OpenSkillLocationOptions, + 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..6472acd684 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 () => ({ contextIds: {}, locations: [] }), 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..f5a2aced2c 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', @@ -158,7 +156,7 @@ type ShellCopy = { actions: { retry: string; }; - paths: Record<'workspace' | 'project' | 'skills', string>; + paths: Record<'workspace' | 'project', string>; errors: { messageRead: string; messageRefresh: string; @@ -212,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 @@ -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' | 'stale_context' | 'missing' | 'blocked_path' | 'read_failed' | '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', @@ -765,7 +761,6 @@ const SHELL_COPY_BY_LOCALE = { paths: { workspace: '工作区文件夹', project: '项目目录', - skills: 'Skills 文件夹', }, errors: { messageRead: '任务内容暂时无法读取,请稍后重试。', @@ -825,7 +820,6 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: (path: string) => `无法打开${path}`, openPathLabels: { workspace: '工作区目录', - skills: 'Skills 目录', memory: '记忆目录', project: '项目目录', }, @@ -915,6 +909,8 @@ const SHELL_COPY_BY_LOCALE = { skillActions: { refreshSkillsFailedTitle: '刷新技能失败', refreshSkillsFallback: '刷新技能失败,请稍后重试。', + refreshLocationsFailedTitle: '刷新技能位置失败', + refreshLocationsFallback: '刷新技能位置失败,请稍后重试。', refreshSourcesFailedTitle: '刷新来源库失败', refreshSourcesFallback: '刷新来源库失败,请稍后重试。', refreshBundledFailedTitle: '刷新内置技能失败', @@ -949,6 +945,17 @@ const SHELL_COPY_BY_LOCALE = { deletedDescription: (id: string) => `${id} 已移除。`, openFailedTitle: '无法打开 Skill', openFallback: '无法打开 Skill,请稍后重试。', + openLocationFailedTitle: '无法打开技能位置', + openLocationFallback: '无法打开技能位置,请稍后重试。', + openLocationFailures: { + unknown_location: '这个技能位置无效。', + stale_context: '技能位置已变化,请重试。', + missing: '目录不存在。', + blocked_path: '技能位置不在允许范围内,已阻止打开。', + read_failed: '无法读取技能目录,请检查文件权限。', + create_failed: '无法创建技能目录,请检查文件权限。', + open_failed: '系统打开目录失败。', + }, openFailures: { invalid_id: 'Skill 名称不在允许范围内。', missing: '没有找到对应的 SKILL.md。', @@ -1272,7 +1279,6 @@ const SHELL_COPY_BY_LOCALE = { paths: { workspace: '工作區資料夾', project: '專案目錄', - skills: 'Skills 資料夾', }, errors: { messageRead: '任務內容暫時無法讀取,請稍後重試。', @@ -1332,7 +1338,6 @@ const SHELL_COPY_BY_LOCALE = { openFailedTitle: (path: string) => `無法開啟${path}`, openPathLabels: { workspace: '工作區目錄', - skills: 'Skills 目錄', memory: '記憶目錄', project: '專案目錄', }, @@ -1422,6 +1427,8 @@ const SHELL_COPY_BY_LOCALE = { skillActions: { refreshSkillsFailedTitle: '重新整理技能失敗', refreshSkillsFallback: '重新整理技能失敗,請稍後重試。', + refreshLocationsFailedTitle: '重新整理技能位置失敗', + refreshLocationsFallback: '重新整理技能位置失敗,請稍後重試。', refreshSourcesFailedTitle: '重新整理來源庫失敗', refreshSourcesFallback: '重新整理來源庫失敗,請稍後重試。', refreshBundledFailedTitle: '重新整理內建技能失敗', @@ -1456,6 +1463,17 @@ const SHELL_COPY_BY_LOCALE = { deletedDescription: (id: string) => `${id} 已移除。`, openFailedTitle: '無法開啟 Skill', openFallback: '無法開啟 Skill,請稍後重試。', + openLocationFailedTitle: '無法開啟技能位置', + openLocationFallback: '無法開啟技能位置,請稍後重試。', + openLocationFailures: { + unknown_location: '這個技能位置無效。', + stale_context: '技能位置已變更,請再試一次。', + missing: '目錄不存在。', + blocked_path: '技能位置不在允許範圍內,已阻止開啟。', + read_failed: '無法讀取技能目錄,請檢查檔案權限。', + create_failed: '無法建立技能目錄,請檢查檔案權限。', + open_failed: '系統無法開啟目錄。', + }, openFailures: { invalid_id: 'Skill 名稱不在允許範圍內。', missing: '沒有找到對應的 SKILL.md。', @@ -1779,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.', @@ -1841,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', }, @@ -1934,6 +1950,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 +1986,17 @@ 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.', + 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.', + 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/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/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/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..64b08c59a6 --- /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 identities bound to this Host and each independently resolved scope. */ + readonly contextIds: Readonly>>; + 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 20f353b627..bbcc32835d 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'; @@ -48,6 +49,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. @@ -78,14 +80,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, @@ -96,24 +99,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, @@ -123,15 +128,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, @@ -144,7 +150,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, @@ -171,14 +177,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, @@ -190,20 +197,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', @@ -692,6 +710,7 @@ function ExtensionsSkillsSurface(props: { badge: {}} />, }} skills={props.skills ?? []} + skillLocations={withSkillLocationCounts(SKILL_DIRECTORIES, props.skills ?? [])} managedSkillSources={[]} bundledSkillCatalog={props.bundledSkillCatalog ?? []} onRefreshSkills={noop} @@ -699,7 +718,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 @@ -838,6 +857,7 @@ function ProductionModuleHubHostSurface() { , + 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 cf5af1d22c..fa63f784bf 100644 --- a/docs/astryx-surface-file-inventory.md +++ b/docs/astryx-surface-file-inventory.md @@ -304,7 +304,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 | diff --git a/docs/windows-test-inventory.md b/docs/windows-test-inventory.md index 253e6d230d..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 | 36 | +| platform-contract | 38 | -Total Windows-excluded declarations: **98** +Total Windows-excluded declarations: **100** ## Inventory @@ -31,10 +31,12 @@ Total Windows-excluded declarations: **98** | 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'` | | 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'` | diff --git a/packages/core/package.json b/packages/core/package.json index fa8215bbcc..458730db7e 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-host/protocol-compatible-changes/external-session-cwd-limit-authority.json b/packages/runtime-host/protocol-compatible-changes/external-session-cwd-limit-authority.json index 6512b91dd2..3f3bde03e2 100644 --- a/packages/runtime-host/protocol-compatible-changes/external-session-cwd-limit-authority.json +++ b/packages/runtime-host/protocol-compatible-changes/external-session-cwd-limit-authority.json @@ -1,5 +1,5 @@ { - "epoch": 164, + "epoch": 165, "files": ["packages/runtime-host/src/protocol/external-session.ts"], "reason": "Re-exports the unchanged 4096-byte cwd limit from its core authority without changing any codec, field, accepted value, or encoded result" } 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 f2ba5b73cd..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[] }; @@ -175,35 +186,28 @@ 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', - }, - { - 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', - }, - ]; +): { 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(root, ...location.segments), + containmentRoot: root, + scope: location.scope, + source: location.source, + refPrefix: location.ref, + }, + ]; + }); 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 new file mode 100644 index 0000000000..25a96b6d93 --- /dev/null +++ b/packages/ui/src/__tests__/skills-panel-locations.test.tsx @@ -0,0 +1,165 @@ +/* + * 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 { afterEach, test } from 'node:test'; +import { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { parseHTML } from 'linkedom'; +import { LocaleProvider } from '../locale-context.js'; +import type { SkillLocation, SkillLocationRef } from '../module-panel-types.js'; +import { SkillsModuleMain } from '../skills-panel.js'; +import { ToastProvider } from '../toast.js'; + +const originalGlobals = { + cancelAnimationFrame: globalThis.cancelAnimationFrame, + document: globalThis.document, + matchMedia: globalThis.matchMedia, + requestAnimationFrame: globalThis.requestAnimationFrame, + window: globalThis.window, +}; +const originalActEnvironment = (globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; +}).IS_REACT_ACT_ENVIRONMENT; +const mountedRoots: ReturnType[] = []; + +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 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', + getPropertyValue: () => '', + }) as unknown as CSSStyleDeclaration; + Object.assign(globalThis, { + document, + window, + matchMedia: () => ({ matches: false, addEventListener() {}, removeEventListener() {} }), + requestAnimationFrame: (callback: FrameRequestCallback) => { + frames.set(++frameId, callback); + return frameId; + }, + cancelAnimationFrame: (id: number) => frames.delete(id), + 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, []); + 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( + 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 132ff4a5b1..b1a5341919 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..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,6 +65,15 @@ export interface SkillEntry { manageable?: boolean; } +export interface SkillLocation { + ref: SkillLocationRef; + scope: SkillLocationDefinition['scope']; + source: SkillLocationDefinition['source']; + 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..1a263463b7 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 ? (