Merge pull request #2576 from openchamber/feat/repository-local-skills-discovery-41dc

fix: discover repository-local .agents skills (#1159)
This commit is contained in:
Serhii Dziupin
2026-08-03 12:20:42 +03:00
committed by GitHub
8 changed files with 439 additions and 69 deletions
+3 -1
View File
@@ -700,7 +700,9 @@ async function performConfigRefresh(options: {
uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined)); uiRefreshTasks.push(commandsStore.loadCommands().then(() => undefined));
} }
if (refreshSkills) { 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(skillsStore.loadSkills().then(() => undefined));
uiRefreshTasks.push(skillsCatalogStore.loadCatalog({ refresh: true }).then(() => undefined)); uiRefreshTasks.push(skillsCatalogStore.loadCatalog({ refresh: true }).then(() => undefined));
} }
+19 -17
View File
@@ -15,6 +15,7 @@ import type {
import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore'; import { invalidateSkillsLoadCache, refreshSkillsAfterOpenCodeRestart, useSkillsStore } from '@/stores/useSkillsStore';
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate'; import { startConfigUpdate, finishConfigUpdate, updateConfigUpdateMessage } from '@/lib/configUpdate';
import { runtimeFetch } from '@/lib/runtime-fetch'; import { runtimeFetch } from '@/lib/runtime-fetch';
@@ -45,20 +46,21 @@ const getSkillsCatalogCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY; return directory?.trim() || DEFAULT_SKILLS_CATALOG_CACHE_KEY;
}; };
const getCurrentDirectory = (): string | null => { const getRequestDirectory = (): string | null => {
const opencodeDirectory = opencodeClient.getDirectory();
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) {
return opencodeDirectory;
}
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const projectsStore = useProjectsStore.getState();
const store = (window as any).__zustand_directory_store__; const activeProject = projectsStore.getActiveProject?.();
if (store) {
return store.getState().currentDirectory; 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; return null;
@@ -118,7 +120,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
setSelectedSource: (id) => set({ selectedSourceId: id }), setSelectedSource: (id) => set({ selectedSourceId: id }),
loadCatalog: async (options) => { loadCatalog: async (options) => {
const currentDirectory = getCurrentDirectory(); const currentDirectory = getRequestDirectory();
const cacheKey = getSkillsCatalogCacheKey(currentDirectory); const cacheKey = getSkillsCatalogCacheKey(currentDirectory);
const now = Date.now(); const now = Date.now();
const loadedAt = skillsCatalogLastLoadedAt.get(cacheKey) ?? 0; const loadedAt = skillsCatalogLastLoadedAt.get(cacheKey) ?? 0;
@@ -222,7 +224,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({ isLoadingSource: true, lastCatalogError: null }); set({ isLoadingSource: true, lastCatalogError: null });
try { try {
const currentDirectory = getCurrentDirectory(); const currentDirectory = getRequestDirectory();
const refresh = options?.refresh ? '&refresh=true' : ''; const refresh = options?.refresh ? '&refresh=true' : '';
const queryParams = currentDirectory const queryParams = currentDirectory
? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}` ? `?directory=${encodeURIComponent(currentDirectory)}&sourceId=${encodeURIComponent(sourceId)}${refresh}`
@@ -293,7 +295,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
set({ isLoadingMore: true }); set({ isLoadingMore: true });
try { try {
const currentDirectory = getCurrentDirectory(); const currentDirectory = getRequestDirectory();
const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`]; const parts = [`sourceId=${encodeURIComponent(selectedSourceId)}`];
if (currentDirectory) { if (currentDirectory) {
parts.push(`directory=${encodeURIComponent(currentDirectory)}`); parts.push(`directory=${encodeURIComponent(currentDirectory)}`);
@@ -355,7 +357,7 @@ export const useSkillsCatalogStore = create<SkillsCatalogState>()(
scanRepo: async (request) => { scanRepo: async (request) => {
set({ isScanning: true, lastScanError: null, scanResults: null }); set({ isScanning: true, lastScanError: null, scanResults: null });
try { try {
const currentDirectory = getCurrentDirectory(); const currentDirectory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await runtimeFetch(`/api/config/skills/scan${queryParams}`, { 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 const directoryOverride = typeof options?.directory === 'string' && options.directory.trim().length > 0
? options.directory.trim() ? options.directory.trim()
: null; : null;
const currentDirectory = directoryOverride ?? getCurrentDirectory(); const currentDirectory = directoryOverride ?? getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : '';
const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, { const response = await runtimeFetch(`/api/config/skills/install${queryParams}`, {
@@ -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<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,
}]);
});
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)}`);
});
});
+66 -41
View File
@@ -10,23 +10,29 @@ import {
import { createDeferredSafeJSONStorage } from "./utils/safeStorage"; import { createDeferredSafeJSONStorage } from "./utils/safeStorage";
import { runtimeFetch } from "@/lib/runtime-fetch"; import { runtimeFetch } from "@/lib/runtime-fetch";
import { runBackgroundNetworkTask } from "@/lib/background-network"; import { runBackgroundNetworkTask } from "@/lib/background-network";
import { useProjectsStore } from "@/stores/useProjectsStore";
import { opencodeClient } from '@/lib/opencode/client'; import { opencodeClient } from '@/lib/opencode/client';
const getCurrentDirectory = (): string | null => { // Prefer the active project path so Settings/Skills discovery matches the
const opencodeDirectory = opencodeClient.getDirectory(); // project selector (and Commands/Agents). Falling back only to the session
if (typeof opencodeDirectory === 'string' && opencodeDirectory.trim().length > 0) { // directory misses repository-local `.agents/skills` when the client directory
return opencodeDirectory; // is unset or points elsewhere while an active project exists.
} const getRequestDirectory = (): string | null => {
try { try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any const projectsStore = useProjectsStore.getState();
const store = (window as any).__zustand_directory_store__; const activeProject = projectsStore.getActiveProject?.();
if (store) {
return store.getState().currentDirectory; 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; return null;
@@ -169,7 +175,7 @@ const getSkillsCacheKey = (directory: string | null): string => {
return directory?.trim() || DEFAULT_SKILLS_CACHE_KEY; 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)); skillsLastLoadedAt.delete(getSkillsCacheKey(directory));
}; };
@@ -198,8 +204,8 @@ export const useSkillsStore = create<SkillsStore>()(
}, },
loadSkills: async () => { loadSkills: async () => {
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const cacheKey = getSkillsCacheKey(currentDirectory); const cacheKey = getSkillsCacheKey(directory);
const now = Date.now(); const now = Date.now();
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0; const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
const hasCachedSkills = get().skills.length > 0; const hasCachedSkills = get().skills.length > 0;
@@ -220,9 +226,12 @@ export const useSkillsStore = create<SkillsStore>()(
for (let attempt = 0; attempt < 3; attempt++) { for (let attempt = 0; attempt < 3; attempt++) {
try { 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) { if (!response.ok) {
throw new Error(`Failed to list skills: ${response.status}`); throw new Error(`Failed to list skills: ${response.status}`);
} }
@@ -263,10 +272,12 @@ export const useSkillsStore = create<SkillsStore>()(
getSkillDetail: async (name: string) => { getSkillDetail: async (name: string) => {
try { try {
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; 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) { if (!response.ok) {
return null; return null;
} }
@@ -291,12 +302,15 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.source) skillConfig.source = config.source; if (config.source) skillConfig.source = config.source;
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles; if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, { const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig) body: JSON.stringify(skillConfig)
}); });
@@ -307,7 +321,7 @@ export const useSkillsStore = create<SkillsStore>()(
} }
const needsReload = payload?.requiresReload ?? false; const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory); invalidateSkillsLoadCache(directory);
if (needsReload) { if (needsReload) {
requiresReload = true; requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({ await refreshSkillsAfterOpenCodeRestart({
@@ -342,12 +356,15 @@ export const useSkillsStore = create<SkillsStore>()(
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles; if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath; if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; 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}`, {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify(skillConfig) body: JSON.stringify(skillConfig)
}); });
@@ -358,7 +375,7 @@ export const useSkillsStore = create<SkillsStore>()(
} }
const needsReload = payload?.requiresReload ?? false; const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory); invalidateSkillsLoadCache(directory);
if (needsReload) { if (needsReload) {
requiresReload = true; requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({ await refreshSkillsAfterOpenCodeRestart({
@@ -386,11 +403,12 @@ export const useSkillsStore = create<SkillsStore>()(
startConfigUpdate("Deleting skill..."); startConfigUpdate("Deleting skill...");
let requiresReload = false; let requiresReload = false;
try { try {
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; 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}`, {
method: 'DELETE' method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}); });
const payload = await response.json().catch(() => null); const payload = await response.json().catch(() => null);
@@ -400,7 +418,7 @@ export const useSkillsStore = create<SkillsStore>()(
} }
const needsReload = payload?.requiresReload ?? false; const needsReload = payload?.requiresReload ?? false;
invalidateSkillsLoadCache(currentDirectory); invalidateSkillsLoadCache(directory);
if (needsReload) { if (needsReload) {
requiresReload = true; requiresReload = true;
await refreshSkillsAfterOpenCodeRestart({ await refreshSkillsAfterOpenCodeRestart({
@@ -436,11 +454,12 @@ export const useSkillsStore = create<SkillsStore>()(
readSupportingFile: async (skillName: string, filePath: string) => { readSupportingFile: async (skillName: string, filePath: string) => {
try { try {
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `&directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch( 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) { if (!response.ok) {
return null; return null;
@@ -455,14 +474,17 @@ export const useSkillsStore = create<SkillsStore>()(
writeSupportingFile: async (skillName: string, filePath: string, content: string) => { writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
try { try {
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch( const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`, `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{ {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: {
'Content-Type': 'application/json',
...(directory ? { 'x-opencode-directory': directory } : {}),
},
body: JSON.stringify({ content }) body: JSON.stringify({ content })
} }
); );
@@ -475,12 +497,15 @@ export const useSkillsStore = create<SkillsStore>()(
deleteSupportingFile: async (skillName: string, filePath: string) => { deleteSupportingFile: async (skillName: string, filePath: string) => {
try { try {
const currentDirectory = getCurrentDirectory(); const directory = getRequestDirectory();
const queryParams = currentDirectory ? `?directory=${encodeURIComponent(currentDirectory)}` : ''; const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch( const response = await runtimeFetch(
`/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`, `/api/config/skills/${encodeURIComponent(skillName)}/files/${encodeURIComponent(filePath)}${queryParams}`,
{ method: 'DELETE' } {
method: 'DELETE',
headers: directory ? { 'x-opencode-directory': directory } : undefined,
}
); );
return response.ok; return response.ok;
@@ -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 config CRUD and metadata under `/api/config/skills*`
- Skills catalog listing/source pagination, scan, and install routes - Skills catalog listing/source pagination, scan, and install routes
- Supporting skill file read/write/delete 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) ## Public exports (proxy.js)
- `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware. - `registerOpenCodeProxy(app, dependencies)`: registers OpenCode proxy routes and middleware.
@@ -200,9 +200,33 @@ export const registerSkillRoutes = (app, dependencies) => {
return null; 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) => { app.get('/api/config/skills', async (req, res) => {
try { try {
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ 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) => { app.get('/api/config/skills/catalog/source', async (req, res) => {
try { try {
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ ok: false, error: { kind: 'invalidSource', message: 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) => { app.get('/api/config/skills/:name', async (req, res) => {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
@@ -546,7 +570,7 @@ export const registerSkillRoutes = (app, dependencies) => {
if (isUnsafeSkillRelativePath(filePath)) { if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' }); return res.status(400).json({ error: 'Invalid file path' });
} }
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
@@ -579,7 +603,7 @@ export const registerSkillRoutes = (app, dependencies) => {
const { scope, source: skillSource, ...config } = req.body; const { scope, source: skillSource, ...config } = req.body;
const { directory, error } = scope === SKILL_SCOPE.PROJECT const { directory, error } = scope === SKILL_SCOPE.PROJECT
? await resolveProjectDirectory(req) ? await resolveProjectDirectory(req)
: await resolveOptionalProjectDirectory(req); : await resolveSkillsDirectory(req);
if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) { if (error || (scope === SKILL_SCOPE.PROJECT && !directory)) {
return res.status(400).json({ error: error || 'Project skill creation requires a directory' }); return res.status(400).json({ error: error || 'Project skill creation requires a directory' });
} }
@@ -606,7 +630,7 @@ export const registerSkillRoutes = (app, dependencies) => {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const updates = req.body; const updates = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ 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' }); return res.status(400).json({ error: 'Invalid file path' });
} }
const { content } = req.body; const { content } = req.body;
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
@@ -671,7 +695,7 @@ export const registerSkillRoutes = (app, dependencies) => {
if (isUnsafeSkillRelativePath(filePath)) { if (isUnsafeSkillRelativePath(filePath)) {
return res.status(400).json({ error: 'Invalid file path' }); return res.status(400).json({ error: 'Invalid file path' });
} }
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ 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) => { app.delete('/api/config/skills/:name', async (req, res) => {
try { try {
const skillName = req.params.name; const skillName = req.params.name;
const { directory, error } = await resolveOptionalProjectDirectory(req); const { directory, error } = await resolveSkillsDirectory(req);
if (error) { if (error) {
return res.status(400).json({ error }); return res.status(400).json({ error });
} }
@@ -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<void> } | 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');
});
});
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
import fsPromises from 'fs/promises'; import fsPromises from 'fs/promises';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import { getSkillSources, mergeDiscoveredSkills } from './skills.js'; import { discoverSkills, getSkillSources, mergeDiscoveredSkills } from './skills.js';
describe('skills', () => { describe('skills', () => {
it('merges locally discovered skills missing from OpenCode live discovery', () => { 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', () => { it('resolves built-in OpenCode skill content without parsing virtual locations as files', () => {
const sources = getSkillSources( const sources = getSkillSources(
'customize-opencode', 'customize-opencode',