fix: discover repository-local .agents skills in Settings

Skills listing ignored the active project when the OpenCode client
directory was unset, so project `.agents/skills` files were created but
never shown. Prefer the active project path (matching Commands/Agents)
and soft-fall back to it on skill API routes when directory is omitted.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-02 12:57:51 +00:00
co-authored by Serhii Dziupin
parent 7afec99f80
commit 049600df72
7 changed files with 420 additions and 68 deletions
+19 -17
View File
@@ -15,6 +15,7 @@ import type {
import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -45,20 +46,21 @@ const getSkillsCatalogCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
};
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
const getRequestDirectory = (): string | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
} catch {
// ignore
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[SkillsCatalogStore] Error resolving config directory:', err);
}
return null;
@@ -118,7 +120,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
setSelectedSource: (id) => set({ selectedSourceId: id }),
loadCatalog: async (options) => {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const cacheKey = getSkillsCatalogCacheKey(currentDirectory);
const now = Date.now();
const loadedAt = skillsCatalogLastLoadedAt.get(cacheKey) ?? 0;
@@ -222,7 +224,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({ isLoadingSource: true, lastCatalogError: null });
try {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
@@ -293,7 +295,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({ isLoadingMore: true });
try {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
if (currentDirectory) {
parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
@@ -355,7 +357,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
scanRepo: async (request) => {
set({ isScanning: true, lastScanError: null, scanResults: null });
try {
const currentDirectory = getCurrentDirectory();
const currentDirectory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await runtimeFetch(`/api/config/skills/scan${queryParams}`, {
@@ -391,7 +393,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim()
: null;
const currentDirectory = directoryOverride ?? getCurrentDirectory();
const currentDirectory = directoryOverride ?? getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, {
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, mock, test } from 'bun:test';
const activeProjectPath = '/workspace/project-with-agents-skills';
let runtimeFetchCalls: Array<{ url: string; headers?: HeadersInit }> = [];
let runtimeFetchImpl: (url: string, init?: RequestInit) => Promise<Response> = async () => (
new Response(JSON.stringify({ skills: [] }), {
headers: { 'Content-Type': 'application/json' },
})
);
let getDirectoryImpl: () => string | undefined = () => undefined;
const runtimeFetchMock = async (url: string, init?: RequestInit) => {
runtimeFetchCalls.push({ url: String(url), headers: init?.headers });
return runtimeFetchImpl(url, init);
};
mock.module('@/lib/opencode/client', () => ({
opencodeClient: {
getDirectory: () => getDirectoryImpl(),
checkHealth: async () => true,
},
}));
mock.module('@/stores/useProjectsStore', () => ({
useProjectsStore: {
getState: () => ({
getActiveProject: () => ({ path: activeProjectPath }),
}),
},
}));
mock.module('@/lib/runtime-fetch', () => ({
runtimeFetch: runtimeFetchMock,
}));
mock.module('@/lib/background-network', () => ({
runBackgroundNetworkTask: async <T,>(task: () => Promise<T>) => task(),
}));
mock.module('@/lib/configUpdate', () => ({
startConfigUpdate: mock(() => undefined),
finishConfigUpdate: mock(() => undefined),
updateConfigUpdateMessage: mock(() => undefined),
}));
mock.module('@/lib/configSync', () => ({
emitConfigChange: mock(() => undefined),
scopeMatches: mock(() => false),
subscribeToConfigChanges: mock(() => () => undefined),
}));
mock.module('./utils/safeStorage', () => ({
createDeferredSafeJSONStorage: () => ({
getItem: async () => null,
setItem: async () => undefined,
removeItem: async () => undefined,
}),
}));
const { invalidateSkillsLoadCache, useSkillsStore } = await import('./useSkillsStore');
describe('useSkillsStore directory resolution', () => {
beforeEach(() => {
runtimeFetchCalls = [];
getDirectoryImpl = () => undefined;
runtimeFetchImpl = async () => new Response(JSON.stringify({
skills: [{
name: 'repo-local-skill',
path: `${activeProjectPath}/.agents/skills/repo-local-skill/SKILL.md`,
scope: 'project',
source: 'agents',
sources: { md: { description: 'Repository local' } },
}],
}), {
headers: { 'Content-Type': 'application/json' },
});
invalidateSkillsLoadCache(activeProjectPath);
useSkillsStore.setState({
selectedSkillName: null,
skills: [],
isLoading: false,
skillDraft: null,
});
});
test('loadSkills scopes discovery to the active project even when client directory is unset', async () => {
const loaded = await useSkillsStore.getState().loadSkills();
expect(loaded).toBe(true);
expect(runtimeFetchCalls.length).toBe(1);
expect(runtimeFetchCalls[0]?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`);
expect(useSkillsStore.getState().skills).toEqual([{
name: 'repo-local-skill',
path: `${activeProjectPath}/.agents/skills/repo-local-skill/SKILL.md`,
scope: 'project',
source: 'agents',
description: 'Repository local',
group: undefined,
}]);
});
});
+66 -41
View File
@@ -10,23 +10,29 @@ import {
import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch";
import { runBackgroundNetworkTask } from "@/lib/background-network";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
// Prefer the active project path so Settings/Skills discovery matches the
// project selector (and Commands/Agents). Falling back only to the session
// directory misses repository-local `.agents/skills` when the client directory
// is unset or points elsewhere while an active project exists.
const getRequestDirectory = (): string | null => {
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const store = (window as any).__zustand_directory_store__;
if (store) {
return store.getState().currentDirectory;
const projectsStore = useProjectsStore.getState();
const activeProject = projectsStore.getActiveProject?.();
if (activeProject?.path?.trim()) {
return activeProject.path.trim();
}
} catch {
// ignore
const clientDir = opencodeClient.getDirectory();
if (clientDir?.trim()) {
return clientDir.trim();
}
} catch (err) {
console.warn('[SkillsStore] Error resolving config directory:', err);
}
return null;
@@ -169,7 +175,7 @@ const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY;
};
export const invalidateSkillsLoadCache = (directory: string | null = getCurrentDirectory()) => {
export const invalidateSkillsLoadCache = (directory: string | null = getRequestDirectory()) => {
skillsLastLoadedAt.delete(getSkillsCacheKey(directory));
};
@@ -198,8 +204,8 @@ export const useSkillsStore = create<SkillsStore>()(
},
loadSkills: async () => {
const currentDirectory = getCurrentDirectory();
const cacheKey = getSkillsCacheKey(currentDirectory);
const directory = getRequestDirectory();
const cacheKey = getSkillsCacheKey(directory);
const now = Date.now();
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedSkills = get().skills.length > 0;
@@ -220,9 +226,12 @@ export const useSkillsStore = create<SkillsStore>()(
for (let attempt = 0; attempt < 3; attempt++) {
try {
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, { priority: 'low' }));
const response = await runBackgroundNetworkTask(() => runtimeFetch(`/api/config/skills${queryParams}`, {
priority: 'low',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}));
if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`);
}
@@ -263,10 +272,12 @@ export const useSkillsStore = create<SkillsStore>()(
getSkillDetail: async (name: string) => {
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}`);
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
if (!response.ok) {
return null;
}
@@ -291,12 +302,15 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.source) skillConfig.source = config.source;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
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(config.name)}${queryParams}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig)
});
@@ -307,7 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory);
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
@@ -342,12 +356,15 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
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(skillConfig)
});
@@ -358,7 +375,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory);
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
@@ -386,11 +403,12 @@ export const useSkillsStore = create<SkillsStore>()(
startConfigUpdate("Deleting 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: 'DELETE'
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
});
const payload = await response.json().catch(() => null);
@@ -400,7 +418,7 @@ export const useSkillsStore = create<SkillsStore>()(
}
const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory);
invalidateSkillsLoadCache(directory);
if (needsReload) {
requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({
@@ -436,11 +454,12 @@ export const useSkillsStore = create<SkillsStore>()(
readSupportingFile: async (skillName: string, filePath: string) => {
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(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}?${queryParams.slice(1)}`,
{ headers: directory ? { 'x-opencode-directory': directory } : undefined },
);
if (!response.ok) {
return null;
@@ -455,14 +474,17 @@ export const useSkillsStore = create<SkillsStore>()(
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
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(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ content })
}
);
@@ -475,12 +497,15 @@ export const useSkillsStore = create<SkillsStore>()(
deleteSupportingFile: async (skillName: string, filePath: string) => {
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(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{ method: 'DELETE' }
{
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}
);
return response.ok;