merge(main): resolve skills.test.js import conflict

Keep both discoverSkills from main and renameSkill from this branch.

Co-authored-by: Serhii Dziupin <makeittech@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-08-03 09:55:56 +00:00
co-authored by Serhii Dziupin
43 changed files with 1969 additions and 244 deletions
@@ -355,6 +355,10 @@ an authoritative loopback callback URL even when OpenChamber binds port `0`.
- Skill list responses include authoritative `renamable` derived from the same managed-root policy used by rename
- 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.
@@ -202,9 +202,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 });
}
@@ -265,7 +289,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 } });
}
@@ -526,7 +550,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 });
}
@@ -554,7 +578,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 });
}
@@ -587,7 +611,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' });
}
@@ -614,7 +638,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 });
}
@@ -661,7 +685,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 });
}
@@ -695,7 +719,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 });
}
@@ -725,7 +749,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');
});
});
@@ -3,7 +3,7 @@ import fs from 'fs';
import fsPromises from 'fs/promises';
import os from 'os';
import path from 'path';
import { getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js';
import { discoverSkills, getSkillSources, mergeDiscoveredSkills, renameSkill } from './skills.js';
describe('skills', () => {
it('merges locally discovered skills missing from OpenCode live discovery', () => {
@@ -25,6 +25,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',