From 0d24d0a167cdd9b10be98a79240be4f54054c796 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 11:05:27 +0000 Subject: [PATCH] fix(skills): repair renameSkill directory resolution after merge Use getRequestDirectory and x-opencode-directory like the other skill mutations, and pin renamable list/store mapping with focused tests. Co-authored-by: Serhii Dziupin --- packages/ui/src/stores/useSkillsStore.test.ts | 85 +++++++++++++++++++ packages/ui/src/stores/useSkillsStore.ts | 11 ++- .../server/lib/opencode/skill-routes.test.js | 62 ++++++++++++++ 3 files changed, 154 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/stores/useSkillsStore.test.ts b/packages/ui/src/stores/useSkillsStore.test.ts index f75fb711..3c569d98 100644 --- a/packages/ui/src/stores/useSkillsStore.test.ts +++ b/packages/ui/src/stores/useSkillsStore.test.ts @@ -98,9 +98,94 @@ describe('useSkillsStore directory resolution', () => { source: 'agents', description: 'Repository local', group: undefined, + renamable: false, }]); }); + test('loadSkills maps authoritative renamable from the list response', async () => { + runtimeFetchImpl = async () => new Response(JSON.stringify({ + skills: [ + { + name: 'managed-skill', + path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + renamable: true, + sources: { md: { description: 'Managed' } }, + }, + { + name: 'cache-skill', + path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', + scope: 'user', + source: 'opencode', + renamable: false, + sources: { md: { description: 'Cache' } }, + }, + ], + }), { + headers: { 'Content-Type': 'application/json' }, + }); + + expect(await useSkillsStore.getState().loadSkills()).toBe(true); + expect(useSkillsStore.getState().skills).toEqual([ + { + name: 'managed-skill', + path: `${activeProjectPath}/.opencode/skills/managed-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + description: 'Managed', + group: undefined, + renamable: true, + }, + { + name: 'cache-skill', + path: '/home/ubuntu/.cache/opencode/skills/hash/cache-skill/SKILL.md', + scope: 'user', + source: 'opencode', + description: 'Cache', + group: 'hash', + renamable: false, + }, + ]); + }); + + test('renameSkill uses getRequestDirectory query and x-opencode-directory header', async () => { + runtimeFetchImpl = async (_url, init) => { + if (init?.method === 'PATCH') { + return new Response(JSON.stringify({ + success: true, + requiresReload: false, + }), { + headers: { 'Content-Type': 'application/json' }, + }); + } + + return new Response(JSON.stringify({ + skills: [{ + name: 'new-skill', + path: `${activeProjectPath}/.opencode/skills/new-skill/SKILL.md`, + scope: 'project', + source: 'opencode', + renamable: true, + sources: { md: { description: 'Renamed' } }, + }], + }), { + headers: { 'Content-Type': 'application/json' }, + }); + }; + + const renamed = await useSkillsStore.getState().renameSkill('old-skill', 'new-skill'); + expect(renamed).toBe(true); + + const renameCall = runtimeFetchCalls.find((call) => String(call.url).includes('/api/config/skills/old-skill')); + expect(renameCall).toBeTruthy(); + expect(renameCall?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); + + const headers = new Headers(renameCall?.headers); + expect(headers.get('content-type')).toBe('application/json'); + expect(headers.get('x-opencode-directory')).toBe(activeProjectPath); + }); + test('invalidateSkillsLoadCache() with no argument clears the active-project cache key used by loadSkills', async () => { expect(await useSkillsStore.getState().loadSkills()).toBe(true); expect(runtimeFetchCalls.length).toBe(1); diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 2614a8df..2157db21 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -408,12 +408,15 @@ export const useSkillsStore = create()( startConfigUpdate("Renaming skill..."); let requiresReload = false; try { - const currentDirectory = getCurrentDirectory(); - const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; + const directory = getRequestDirectory(); + const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : ''; const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, { method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + ...(directory ? { 'x-opencode-directory': directory } : {}), + }, body: JSON.stringify({ renameTo: newName }), }); @@ -424,7 +427,7 @@ export const useSkillsStore = create()( } const needsReload = payload?.requiresReload ?? false; - invalidateSkillsLoadCache(currentDirectory); + invalidateSkillsLoadCache(directory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ diff --git a/packages/web/server/lib/opencode/skill-routes.test.js b/packages/web/server/lib/opencode/skill-routes.test.js index c83f7f84..3ba8526e 100644 --- a/packages/web/server/lib/opencode/skill-routes.test.js +++ b/packages/web/server/lib/opencode/skill-routes.test.js @@ -9,7 +9,9 @@ import { deleteSkill, discoverSkills, getSkillSources, + isManagedSkillPath, mergeDiscoveredSkills, + renameSkill, updateSkill, } from './skills.js'; import { @@ -58,6 +60,8 @@ const startSkillsApp = ({ projectRoot }) => { createSkill, updateSkill, deleteSkill, + renameSkill, + isManagedSkillPath, readSkillSupportingFile, writeSkillSupportingFile, deleteSkillSupportingFile, @@ -154,4 +158,62 @@ describe('skill-routes directory soft fallback', () => { const payload = await listResponse.json(); expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill'); }); + + it('marks managed-root skills renamable and cache skills not renamable', async () => { + projectRoot = createTempProject(); + const managedDir = path.join(projectRoot, '.opencode', 'skills', 'managed-list-skill'); + fs.mkdirSync(managedDir, { recursive: true }); + fs.writeFileSync( + path.join(managedDir, 'SKILL.md'), + [ + '---', + 'name: managed-list-skill', + 'description: Managed list skill', + '---', + '', + 'Managed body', + '', + ].join('\n'), + 'utf8', + ); + + const cacheStamp = `oc-skill-routes-${Date.now()}`; + const cacheDir = path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp, 'cache-list-skill'); + fs.mkdirSync(cacheDir, { recursive: true }); + fs.writeFileSync( + path.join(cacheDir, 'SKILL.md'), + [ + '---', + 'name: cache-list-skill', + 'description: Cache list skill', + '---', + '', + 'Cache body', + '', + ].join('\n'), + 'utf8', + ); + + try { + appHandle = startSkillsApp({ projectRoot }); + const listResponse = await fetch( + `${appHandle.baseUrl}/api/config/skills?directory=${encodeURIComponent(projectRoot)}`, + ); + expect(listResponse.status).toBe(200); + const payload = await listResponse.json(); + + const managed = payload.skills.find((entry) => entry.name === 'managed-list-skill'); + const cached = payload.skills.find((entry) => entry.name === 'cache-list-skill'); + + expect(managed).toBeTruthy(); + expect(managed.renamable).toBe(true); + expect(cached).toBeTruthy(); + expect(cached.renamable).toBe(false); + } finally { + fs.rmSync(path.join(os.homedir(), '.cache', 'opencode', 'skills', cacheStamp), { + recursive: true, + force: true, + }); + } + }); });