* Skills HTTP shim — translates the existing `/api/skills/*` REST
* contract that `ui/src/components/main-content-v2/SkillsV2.tsx` was
* built against into the gateway's `skill_*` RPCs. The gateway is the
* authoritative skill manager (see `src/extension/skills/SkillManager.ts`)
* backed by `~/.pilotdeck/skills/` and `<project>/.pilotdeck/skills/`,
* so the UI and the agent always read from the same place.
*
* Two endpoints stay file-based for now because they don't map cleanly
* onto a single gateway RPC:
*
* - `/import-upload` — multipart browser folder picker. We stream the
* buffers into the project skill dir directly, then ask the gateway
* to refresh its in-memory caches via a follow-up `skill_validate`
* call to compute the validation result. A future revision can lift
* this onto a gateway RPC that accepts base64 chunks.
*
* - `/clawhub/*` — shells out to the `clawhub` CLI which writes its
* output to disk by itself. We just retarget the install root to
* `~/.pilotdeck/skills/` so installs end up where the agent looks.
*
* Anything else (list/read/write/create/delete/import/validate/scan) is
* a one-line forward to the gateway. Errors raised by `SkillManagerError`
* arrive as `{ code, message }` and we map their `code` to a sensible
* HTTP status; everything else falls through as 500.
*/
import express from 'express';
import { promises as fs } from 'fs';
import path from 'path';
import os from 'os';
import { execFile } from 'child_process';
import { promisify } from 'util';
import multer from 'multer';
import { getPilotDeckGateway } from '../pilotdeck-bridge.js';
import { resolvePilotHome } from '../utils/pilotPaths.js';
const execFileAsync = promisify(execFile);
const router = express.Router();
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 10 * 1024 * 1024,
files: 500,
fields: 20,
},
});
const SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/;
const PILOT_HOME = resolvePilotHome(process.env);
const PROJECT_DIR = '.pilotdeck';
const SKILLS_SUBDIR = 'skills';
function safeSlug(slug) {
return typeof slug === 'string' && SLUG_RE.test(slug) && !slug.includes('..');
}
const GENERAL_CWD_PATHS = [path.resolve(PILOT_HOME)];
function isGeneralCwd(projectPath) {
if (!projectPath) return false;
return GENERAL_CWD_PATHS.includes(path.resolve(projectPath));
}
function userSkillsRoot() {
return path.join(PILOT_HOME, SKILLS_SUBDIR);
}
function projectSkillsRoot(projectPath) {
return path.join(projectPath, PROJECT_DIR, SKILLS_SUBDIR);
}
function expandHome(p) {
if (typeof p !== 'string' || !p) return p;
if (p === '~') return os.homedir();
if (p.startsWith('~/')) return path.join(os.homedir(), p.slice(2));
return p;
}
* Translate an absolute `skillPath` (used by the UI for stable
* addressing across the read/write/delete cycle) into the
* `(scope, slug)` pair the gateway expects. Refuses anything outside
* the user or active project skill roots so a malformed UI request
* cannot cajole the gateway into touching arbitrary paths.
*/
function classifySkillPath(skillPath, projectPath = null) {
if (typeof skillPath !== 'string' || !skillPath) {
return { ok: false, reason: 'skillPath is required' };
}
const abs = path.resolve(skillPath);
if (abs.includes('..')) {
return { ok: false, reason: 'skillPath contains ".."' };
}
const candidates = [{ root: userSkillsRoot(), scope: 'user' }];
if (projectPath && !isGeneralCwd(projectPath)) {
candidates.push({ root: projectSkillsRoot(projectPath), scope: 'project' });
}
for (const { root, scope } of candidates) {
const rootResolved = path.resolve(root);
if (abs === rootResolved) {
return { ok: false, reason: 'skillPath is the skills root, not a skill' };
}
const rel = path.relative(rootResolved, abs);
if (rel.startsWith('..') || path.isAbsolute(rel)) continue;
const segments = rel.split(path.sep).filter(Boolean);
if (segments.length === 0) continue;
const slug = segments[0];
if (!safeSlug(slug)) {
return { ok: false, reason: `Invalid slug "${slug}"` };
}
return { ok: true, scope, slug };
}
return { ok: false, reason: 'skillPath is not inside any known skills root' };
}
* Convert a gateway error (from a `SkillManagerError` on the other side
* of the WS bridge) into an HTTP status + payload. The gateway sends
* structured `{ code, message, validation? }` errors when the failure
* originated in the skill manager; everything else surfaces as 500.
*/
function sendGatewayError(res, err) {
const code = err?.code;
const message = err?.message || (err instanceof Error ? err.message : String(err));
switch (code) {
case 'not_configured':
return res.status(503).json({ error: message, code });
case 'invalid_input':
case 'invalid_slug':
case 'project_required':
case 'self_import':
return res.status(400).json({ error: message, code });
case 'not_found':
case 'source_missing':
case 'source_not_directory':
case 'no_skill_md':
return res.status(404).json({ error: message, code });
case 'conflict':
return res.status(409).json({ error: message, code });
case 'validation_failed':
return res.status(422).json({ error: message, code, validation: err.validation });
default:
console.error('[skills-bridge]', err);
return res.status(500).json({ error: message, code: code || 'gateway_request_failed' });
}
}
* Wrapper that calls a gateway RPC and normalises errors. The remote
* gateway raises `GatewayRequestError` instances (see
* `src/gateway/client/GatewayWsClient.ts`) which carry the structured
* `code` from `SkillManagerError` plus an optional `validation`
* payload — we let them propagate as-is so `sendGatewayError` can map
* the code to an HTTP status. Transport-level failures (WS closed,
* timeout) surface as plain `Error` and route to the 500 fallback.
*/
async function callGateway(method, params) {
const gw = await getPilotDeckGateway();
return gw[method](params);
}
router.post('/list', async (req, res) => {
try {
const { projectPath } = req.body || {};
const generalCwd = isGeneralCwd(projectPath);
const effectiveProjectPath = generalCwd ? null : projectPath || null;
const data = await callGateway('skillsList', { projectKey: effectiveProjectPath });
res.json({
user: data.user,
project: data.project,
projectPath: data.projectPath,
isGeneralCwd: generalCwd,
});
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/read', async (req, res) => {
try {
const { skillPath, projectPath } = req.body || {};
const cls = classifySkillPath(skillPath, projectPath);
if (!cls.ok) return res.status(400).json({ error: cls.reason });
const result = await callGateway('skillRead', {
scope: cls.scope,
slug: cls.slug,
projectKey: cls.scope === 'project' ? projectPath : null,
});
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/write', async (req, res) => {
try {
const { skillPath, content, projectPath } = req.body || {};
if (typeof content !== 'string') {
return res.status(400).json({ error: 'content (string) is required' });
}
const cls = classifySkillPath(skillPath, projectPath);
if (!cls.ok) return res.status(400).json({ error: cls.reason });
const result = await callGateway('skillWrite', {
scope: cls.scope,
slug: cls.slug,
projectKey: cls.scope === 'project' ? projectPath : null,
content,
});
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/create', async (req, res) => {
try {
const { scope, projectPath, slug, name, description, body, content } = req.body || {};
const wantProject = scope === 'project';
if (wantProject && (!projectPath || isGeneralCwd(projectPath))) {
return res.status(400).json({
error: "project scope requires a real project (general chat doesn't qualify)",
});
}
const result = await callGateway('skillCreate', {
scope: wantProject ? 'project' : 'user',
slug,
projectKey: wantProject ? projectPath : null,
name,
description,
body,
content,
});
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/delete', async (req, res) => {
try {
const { skillPath, projectPath } = req.body || {};
const cls = classifySkillPath(skillPath, projectPath);
if (!cls.ok) return res.status(400).json({ error: cls.reason });
const result = await callGateway('skillDelete', {
scope: cls.scope,
slug: cls.slug,
projectKey: cls.scope === 'project' ? projectPath : null,
});
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/validate', async (req, res) => {
try {
const { sourcePath, skillMdContent, files } = req.body || {};
const result = await callGateway(
'skillValidate',
sourcePath ? { sourcePath } : { skillMdContent, files },
);
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/import', async (req, res) => {
try {
const { sourcePath, slug, scope, projectPath, mode, force } = req.body || {};
const wantProject = scope === 'project';
if (wantProject && (!projectPath || isGeneralCwd(projectPath))) {
return res.status(400).json({
error: "project scope requires a real project (general chat doesn't qualify)",
});
}
const result = await callGateway('skillImport', {
sourcePath,
slug,
scope: wantProject ? 'project' : 'user',
projectKey: wantProject ? projectPath : null,
mode,
force,
});
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/scan', async (req, res) => {
try {
const { parentPath } = req.body || {};
const result = await callGateway('skillScan', { parentPath });
res.json(result);
} catch (e) {
sendGatewayError(res, e);
}
});
router.post('/import-upload', upload.array('files', 500), async (req, res) => {
let stagingDir = null;
try {
const { slug: requestedSlug, scope, projectPath, force, paths: pathsJson } = req.body || {};
let paths;
try {
paths = JSON.parse(pathsJson || '[]');
} catch {
return res
.status(400)
.json({ error: '`paths` must be a JSON array of relative paths matching the file order.' });
}
const filesIn = Array.isArray(req.files) ? req.files : [];
if (filesIn.length === 0) return res.status(400).json({ error: 'No files were uploaded.' });
if (filesIn.length !== paths.length) {
return res.status(400).json({
error: `paths length (${paths.length}) does not match files count (${filesIn.length}).`,
});
}
const manifest = filesIn.map((f, i) => ({
relativePath: paths[i],
size: f.size,
buffer: f.buffer,
}));
let skillMdContent = '';
for (const m of manifest) {
if (m.relativePath === 'SKILL.md') {
skillMdContent = m.buffer.toString('utf8');
break;
}
}
const validation = await callGateway('skillValidate', {
skillMdContent,
files: manifest.map((m) => ({ relativePath: m.relativePath, size: m.size })),
});
if (!validation.ok) {
return res.status(422).json({ error: 'Validation failed', validation });
}
const wantProject = scope === 'project';
if (wantProject && (!projectPath || isGeneralCwd(projectPath))) {
return res
.status(400)
.json({ error: "project scope requires a real project (general chat doesn't qualify)." });
}
const root = wantProject ? projectSkillsRoot(projectPath) : userSkillsRoot();
const inferredSlug =
(typeof requestedSlug === 'string' && requestedSlug.trim()) ||
(paths[0] && paths[0].split('/')[0]) ||
'';
if (!safeSlug(inferredSlug)) {
return res.status(400).json({
error: `Invalid slug "${inferredSlug}". Allowed: [a-zA-Z0-9][a-zA-Z0-9._-]{0,99}, no "..".`,
});
}
const targetDir = path.join(root, inferredSlug);
const stripPrefix = (() => {
const first = paths[0]?.split('/')?.[0];
if (!first) return null;
return paths.every((p) => p.split('/')[0] === first) ? first + '/' : null;
})();
let exists = false;
try {
await fs.access(targetDir);
exists = true;
} catch {
}
if (exists) {
const isForce = force === 'true' || force === true;
if (!isForce) {
return res
.status(409)
.json({ error: `Skill already exists at ${targetDir}. Re-submit with force=true to overwrite.` });
}
}
stagingDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-upload-'));
for (const m of manifest) {
const rel =
stripPrefix && m.relativePath.startsWith(stripPrefix)
? m.relativePath.slice(stripPrefix.length)
: m.relativePath;
if (rel.includes('..') || path.isAbsolute(rel)) continue;
const out = path.join(stagingDir, rel);
await fs.mkdir(path.dirname(out), { recursive: true });
await fs.writeFile(out, m.buffer);
}
await fs.mkdir(root, { recursive: true });
if (exists) await fs.rm(targetDir, { recursive: true, force: true });
await fs.rename(stagingDir, targetDir);
stagingDir = null;
let skillSummary = null;
try {
const list = await callGateway('skillsList', {
projectKey: wantProject ? projectPath : null,
});
const bucket = wantProject ? list.project : list.user;
skillSummary = bucket.find((s) => s.slug === inferredSlug) ?? null;
} catch {
}
res.json({
ok: true,
mode: 'upload',
scope: wantProject ? 'project' : 'user',
slug: inferredSlug,
skillPath: targetDir,
skill: skillSummary,
validation,
});
} catch (e) {
if (stagingDir) {
try {
await fs.rm(stagingDir, { recursive: true, force: true });
} catch {
}
}
sendGatewayError(res, e);
}
});
router.post('/clawhub/search', async (req, res) => {
try {
const { query, registry } = req.body || {};
if (typeof query !== 'string' || query.trim().length === 0) {
return res.json({ results: [] });
}
const args = ['--no-input'];
if (registry) args.push('--registry', registry);
args.push('search', query.trim());
let stdout = '';
try {
const r = await execFileAsync('clawhub', args, { timeout: 30_000, maxBuffer: 4 * 1024 * 1024 });
stdout = r.stdout || '';
} catch (e) {
if (e.code === 'ENOENT') {
return res
.status(503)
.json({ error: 'clawhub CLI not found in PATH. Install with `npm install -g clawhub`.' });
}
stdout = e.stdout || '';
if (!stdout) {
return res.status(500).json({ error: 'clawhub search failed', message: e.message });
}
}
const ANSI = /\x1b\[[0-9;]*m/g;
const results = [];
for (const rawLine of stdout.split('\n')) {
const line = rawLine.replace(ANSI, '').trim();
if (!line) continue;
if (line.startsWith('-') || line.toLowerCase().startsWith('searching')) continue;
const m = line.match(/^(\S+)\s+(.+?)\s+\(([\d.]+)\)\s*$/);
if (m) {
results.push({ slug: m[1], name: m[2], score: parseFloat(m[3]) });
} else {
const parts = line.split(/\s{2,}/);
if (parts.length >= 1 && safeSlug(parts[0])) {
results.push({ slug: parts[0], name: parts[1] || parts[0], score: null });
}
}
}
res.json({ results });
} catch (e) {
console.error('[skills/clawhub/search]', e);
res.status(500).json({ error: 'Search failed', message: e.message });
}
});
router.post('/clawhub/install', async (req, res) => {
try {
const { slug, version, force, scope, projectPath, registry } = req.body || {};
if (!safeSlug(slug)) {
return res.status(400).json({ error: `Invalid slug "${slug}".` });
}
const generalCwd = isGeneralCwd(projectPath);
const effectiveProjectPath = generalCwd ? null : projectPath || null;
const resolvedScope =
scope === 'project' || scope === 'user' ? scope : effectiveProjectPath ? 'project' : 'user';
let workdir;
let dir;
if (resolvedScope === 'project') {
if (!effectiveProjectPath) {
return res.status(400).json({ error: 'project scope requires a real project context' });
}
workdir = effectiveProjectPath;
dir = path.join(PROJECT_DIR, SKILLS_SUBDIR);
} else {
workdir = PILOT_HOME;
dir = SKILLS_SUBDIR;
}
const installPath = path.join(workdir, dir, slug);
const args = ['--no-input', '--workdir', workdir, '--dir', dir];
if (registry) args.push('--registry', registry);
args.push('install', slug);
if (version) args.push('--version', version);
if (force) args.push('--force');
let stdout = '';
let stderr = '';
let runError = null;
try {
const r = await execFileAsync('clawhub', args, { timeout: 120_000, maxBuffer: 10 * 1024 * 1024 });
stdout = r.stdout || '';
stderr = r.stderr || '';
} catch (e) {
if (e.code === 'ENOENT') {
return res
.status(503)
.json({ error: 'clawhub CLI not found in PATH. Install with `npm install -g clawhub`.' });
}
runError = e;
stdout = e.stdout || '';
stderr = e.stderr || '';
}
let installed = false;
let skill = null;
try {
await fs.access(path.join(installPath, 'SKILL.md'));
installed = true;
const list = await callGateway('skillsList', { projectKey: effectiveProjectPath });
const bucket = resolvedScope === 'project' ? list.project : list.user;
skill = bucket.find((s) => s.slug === slug) ?? null;
} catch {
}
const needsForce =
!installed && !force && (stderr || stdout).match(/Use --force to install suspicious/i) !== null;
res.json({
ok: installed,
slug,
scope: resolvedScope,
installPath,
installed,
skill,
stdout: stdout.trim(),
stderr: stderr.trim(),
exitCode: runError ? (runError.code === undefined ? 1 : runError.code) : 0,
needsForce,
});
} catch (e) {
console.error('[skills/clawhub/install]', e);
res.status(500).json({ error: 'Install failed', message: e.message });
}
});
export default router;