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:
co-authored by
Serhii Dziupin
parent
7afec99f80
commit
049600df72
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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 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',
|
||||
|
||||
Reference in New Issue
Block a user