diff --git a/packages/ui/src/stores/useAgentsStore.ts b/packages/ui/src/stores/useAgentsStore.ts index 1412bc0a..4e7beef2 100644 --- a/packages/ui/src/stores/useAgentsStore.ts +++ b/packages/ui/src/stores/useAgentsStore.ts @@ -700,7 +700,9 @@ async function performConfigRefresh(options: { uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined)); } if (refreshSkills) { - invalidateSkillsLoadCache(currentDirectory); + // Match loadSkills cache key (active-project-first). Passing client/directory-store + // path here misses the key when those diverge after getRequestDirectory(). + invalidateSkillsLoadCache(); uiRefreshTasks.push(skillsStore.loadSkills().then(() => undefined)); uiRefreshTasks.push(skillsCatalogStore.loadCatalog({ refresh: true }).then(() => undefined)); } diff --git a/packages/ui/src/stores/useSkillsCatalogStore.ts b/packages/ui/src/stores/useSkillsCatalogStore.ts index 139348fb..6585d152 100644 --- a/packages/ui/src/stores/useSkillsCatalogStore.ts +++ b/packages/ui/src/stores/useSkillsCatalogStore.ts @@ -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()( 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()( 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()( 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()( 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()( 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}`, { diff --git a/packages/ui/src/stores/useSkillsStore.test.ts b/packages/ui/src/stores/useSkillsStore.test.ts new file mode 100644 index 00000000..f75fb711 --- /dev/null +++ b/packages/ui/src/stores/useSkillsStore.test.ts @@ -0,0 +1,119 @@ +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 = 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 (task: () => Promise) => 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, + }]); + }); + + 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); + + // Wrong key: client-directory-first null maps to __default__, not the active project. + invalidateSkillsLoadCache(null); + expect(await useSkillsStore.getState().loadSkills()).toBe(true); + expect(runtimeFetchCalls.length).toBe(1); + + // Default resolution must match loadSkills (active project first). + invalidateSkillsLoadCache(); + expect(await useSkillsStore.getState().loadSkills()).toBe(true); + expect(runtimeFetchCalls.length).toBe(2); + expect(runtimeFetchCalls[1]?.url).toContain(`directory=${encodeURIComponent(activeProjectPath)}`); + }); +}); diff --git a/packages/ui/src/stores/useSkillsStore.ts b/packages/ui/src/stores/useSkillsStore.ts index 2ffe69dd..eab17af2 100644 --- a/packages/ui/src/stores/useSkillsStore.ts +++ b/packages/ui/src/stores/useSkillsStore.ts @@ -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()( }, 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()( 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()( 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()( 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()( } const needsReload = payload?.requiresReload ?? false; - invalidateSkillsLoadCache(currentDirectory); + invalidateSkillsLoadCache(directory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ @@ -342,12 +356,15 @@ export const useSkillsStore = create()( 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()( } const needsReload = payload?.requiresReload ?? false; - invalidateSkillsLoadCache(currentDirectory); + invalidateSkillsLoadCache(directory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ @@ -386,11 +403,12 @@ export const useSkillsStore = create()( 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()( } const needsReload = payload?.requiresReload ?? false; - invalidateSkillsLoadCache(currentDirectory); + invalidateSkillsLoadCache(directory); if (needsReload) { requiresReload = true; await refreshSkillsAfterOpenCodeRestart({ @@ -436,11 +454,12 @@ export const useSkillsStore = create()( 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()( 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()( 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; diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index c57ecb8b..becb92d8 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -353,6 +353,10 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`. - Skills config CRUD and metadata under `/api/config/skills*` - Skills catalog listing/source pagination, scan, and install routes - Supporting skill file read/write/delete routes + - Directory resolution prefers an explicit request directory, then soft-falls + back to the active project / `lastDirectory` so repository-local + `.agents/skills` and `.opencode/skills` remain discoverable when the client + omits `directory`. Requests without any project still list user-scoped skills. ## Public exports (proxy.js) - `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware. diff --git a/packages/web/server/lib/opencode/skill-routes.js b/packages/web/server/lib/opencode/skill-routes.js index b66680a5..ad8dae59 100644 --- a/packages/web/server/lib/opencode/skill-routes.js +++ b/packages/web/server/lib/opencode/skill-routes.js @@ -200,9 +200,33 @@ export const registerSkillRoutes = (app, dependencies) => { return null; }; + // Prefer an explicit request directory, then soft-fallback to the active + // project / lastDirectory so repository-local skills stay visible when the + // client omits `directory` (create already used resolveProjectDirectory). + const resolveSkillsDirectory = async (req) => { + const optional = await resolveOptionalProjectDirectory(req); + if (optional.error) { + return optional; + } + if (optional.directory) { + return optional; + } + + try { + const fallback = await resolveProjectDirectory(req); + if (fallback.directory) { + return { directory: fallback.directory, error: null }; + } + } catch { + // ignore — listing user-scoped skills without a project is valid + } + + return { directory: null, error: null }; + }; + app.get('/api/config/skills', async (req, res) => { try { - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } @@ -257,7 +281,7 @@ export const registerSkillRoutes = (app, dependencies) => { app.get('/api/config/skills/catalog/source', async (req, res) => { try { - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: error } }); } @@ -518,7 +542,7 @@ export const registerSkillRoutes = (app, dependencies) => { app.get('/api/config/skills/:name', async (req, res) => { try { const skillName = req.params.name; - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } @@ -546,7 +570,7 @@ export const registerSkillRoutes = (app, dependencies) => { if (isUnsafeSkillRelativePath(filePath)) { return res.status(400).json({ error: 'Invalid file path' }); } - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } @@ -579,7 +603,7 @@ export const registerSkillRoutes = (app, dependencies) => { const { scope, source: skillSource, ...config } = req.body; const { directory, error } = scope === SKILL_SCOPE.PROJECT ? await resolveProjectDirectory(req) - : await resolveOptionalProjectDirectory(req); + : await resolveSkillsDirectory(req); if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) { return res.status(400).json({ error: error || 'Project skill creation requires a directory' }); } @@ -606,7 +630,7 @@ export const registerSkillRoutes = (app, dependencies) => { try { const skillName = req.params.name; const updates = req.body; - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } @@ -637,7 +661,7 @@ export const registerSkillRoutes = (app, dependencies) => { return res.status(400).json({ error: 'Invalid file path' }); } const { content } = req.body; - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } @@ -671,7 +695,7 @@ export const registerSkillRoutes = (app, dependencies) => { if (isUnsafeSkillRelativePath(filePath)) { return res.status(400).json({ error: 'Invalid file path' }); } - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } @@ -701,7 +725,7 @@ export const registerSkillRoutes = (app, dependencies) => { app.delete('/api/config/skills/:name', async (req, res) => { try { const skillName = req.params.name; - const { directory, error } = await resolveOptionalProjectDirectory(req); + const { directory, error } = await resolveSkillsDirectory(req); if (error) { return res.status(400).json({ error }); } diff --git a/packages/web/server/lib/opencode/skill-routes.test.js b/packages/web/server/lib/opencode/skill-routes.test.js new file mode 100644 index 00000000..c83f7f84 --- /dev/null +++ b/packages/web/server/lib/opencode/skill-routes.test.js @@ -0,0 +1,157 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { registerSkillRoutes } from './skill-routes.js'; +import { + createSkill, + deleteSkill, + discoverSkills, + getSkillSources, + mergeDiscoveredSkills, + updateSkill, +} from './skills.js'; +import { + SKILL_DIR, + SKILL_SCOPE, + deleteSkillSupportingFile, + readSkillSupportingFile, + writeSkillSupportingFile, +} from './shared.js'; + +const createTempProject = () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'oc-skill-routes-')); + fs.mkdirSync(path.join(projectRoot, '.git')); + return projectRoot; +}; + +const startSkillsApp = ({ projectRoot }) => { + const app = express(); + app.use(express.json()); + + registerSkillRoutes(app, { + fs, + path, + os, + resolveProjectDirectory: async () => ({ directory: projectRoot, error: null }), + resolveOptionalProjectDirectory: async (req) => { + const queryDirectory = Array.isArray(req.query?.directory) + ? req.query.directory[0] + : req.query?.directory; + if (!queryDirectory) { + return { directory: null, error: null }; + } + return { directory: String(queryDirectory), error: null }; + }, + readSettingsFromDisk: async () => ({}), + sanitizeSkillCatalogs: (value) => value, + isUnsafeSkillRelativePath: () => false, + refreshOpenCodeAfterConfigChange: async () => {}, + clientReloadDelayMs: 0, + buildOpenCodeUrl: () => 'http://127.0.0.1:9/', + getOpenCodeAuthHeaders: () => ({}), + getOpenCodePort: () => 0, + getSkillSources, + discoverSkills, + mergeDiscoveredSkills, + createSkill, + updateSkill, + deleteSkill, + readSkillSupportingFile, + writeSkillSupportingFile, + deleteSkillSupportingFile, + SKILL_SCOPE, + SKILL_DIR, + getCuratedSkillsSources: () => [], + getCacheKey: () => 'k', + getCachedScan: () => null, + setCachedScan: () => {}, + parseSkillRepoSource: () => ({ ok: false }), + scanSkillsRepository: async () => ({ ok: false }), + installSkillsFromRepository: async () => ({ ok: false }), + scanClawdHubPage: async () => ({ ok: false }), + installSkillsFromClawdHub: async () => ({ ok: false }), + isClawdHubSource: () => false, + getProfiles: () => [], + getProfile: () => null, + }); + + const server = app.listen(0); + const { port } = server.address(); + return { + baseUrl: `http://127.0.0.1:${port}`, + close: () => new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }), + }; +}; + +describe('skill-routes directory soft fallback', () => { + /** @type {string | null} */ + let projectRoot = null; + /** @type {{ close: () => Promise } | null} */ + let appHandle = null; + + afterEach(async () => { + if (appHandle) { + await appHandle.close(); + appHandle = null; + } + if (projectRoot) { + fs.rmSync(projectRoot, { recursive: true, force: true }); + projectRoot = null; + } + }); + + it('lists repository-local .agents skills after create even when list omits directory', async () => { + projectRoot = createTempProject(); + appHandle = startSkillsApp({ projectRoot }); + + const createResponse = await fetch(`${appHandle.baseUrl}/api/config/skills/repo-local-skill`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + description: 'Created without list directory', + instructions: 'Do the thing.', + scope: 'project', + source: 'agents', + }), + }); + expect(createResponse.status).toBe(200); + expect(fs.existsSync(path.join(projectRoot, '.agents', 'skills', 'repo-local-skill', 'SKILL.md'))).toBe(true); + + const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`); + expect(listResponse.status).toBe(200); + const payload = await listResponse.json(); + expect(payload.skills.map((skill) => skill.name)).toContain('repo-local-skill'); + const skill = payload.skills.find((entry) => entry.name === 'repo-local-skill'); + expect(skill.scope).toBe('project'); + expect(skill.source).toBe('agents'); + }); + + it('lists manually created repository-local .agents skills via active-project fallback', async () => { + projectRoot = createTempProject(); + const skillDir = path.join(projectRoot, '.agents', 'skills', 'manual-repo-skill'); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + [ + '---', + 'name: manual-repo-skill', + 'description: Manual repository skill', + '---', + '', + 'Instructions', + '', + ].join('\n'), + 'utf8', + ); + + appHandle = startSkillsApp({ projectRoot }); + const listResponse = await fetch(`${appHandle.baseUrl}/api/config/skills`); + expect(listResponse.status).toBe(200); + const payload = await listResponse.json(); + expect(payload.skills.map((skill) => skill.name)).toContain('manual-repo-skill'); + }); +}); diff --git a/packages/web/server/lib/opencode/skills.test.js b/packages/web/server/lib/opencode/skills.test.js index 95f9c722..ffa3a6f2 100644 --- a/packages/web/server/lib/opencode/skills.test.js +++ b/packages/web/server/lib/opencode/skills.test.js @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import fsPromises from 'fs/promises'; import os from 'os'; import path from 'path'; -import { getSkillSources, mergeDiscoveredSkills } from './skills.js'; +import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js'; describe('skills', () => { it('merges locally discovered skills missing from OpenCode live discovery', () => { @@ -24,6 +24,43 @@ describe('skills', () => { ]); }); + it('discovers repository-local .agents skills for the project directory', async () => { + const tempRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'oc-project-agents-')); + const skillDir = path.join(tempRoot, '.agents', 'skills', 'repo-local-skill'); + const skillPath = path.join(skillDir, 'SKILL.md'); + + try { + await fsPromises.mkdir(skillDir, { recursive: true }); + await fsPromises.mkdir(path.join(tempRoot, '.git')); + await fsPromises.writeFile( + skillPath, + [ + '---', + 'name: repo-local-skill', + 'description: Repository-local agents skill', + '---', + '', + 'Use this skill in this repository.', + '', + ].join('\n'), + 'utf8', + ); + + const discovered = discoverSkills(tempRoot); + const match = discovered.find((skill) => skill.name === 'repo-local-skill'); + + expect(match).toEqual({ + name: 'repo-local-skill', + path: skillPath, + scope: 'project', + source: 'agents', + description: 'Repository-local agents skill', + }); + } finally { + await fsPromises.rm(tempRoot, { recursive: true, force: true }); + } + }); + it('resolves built-in OpenCode skill content without parsing virtual locations as files', () => { const sources = getSkillSources( 'customize-opencode',