| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611 |
- /**
- * 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 a staging dir next to the target skill root, 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';
- import { moveDirectoryAcrossDevicesSafe } from '../utils/fileMoves.js';
- const execFileAsync = promisify(execFile);
- const router = express.Router();
- const upload = multer({
- storage: multer.memoryStorage(),
- limits: {
- fileSize: 10 * 1024 * 1024,
- files: 500,
- fields: 20,
- },
- });
- // ---------------------------------------------------------------------------
- // Path / scope helpers (small surface area kept in the bridge for protocol
- // translation; the SkillManager owns the same logic internally for direct
- // gateway callers, but the UI sends absolute `skillPath` so we need to
- // classify it before forwarding `(scope, slug)`).
- // ---------------------------------------------------------------------------
- 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 resolveRequestedScope(scope, projectPath, { defaultToProjectWhenAvailable = false } = {}) {
- const generalCwd = isGeneralCwd(projectPath);
- const effectiveProjectPath = generalCwd ? null : projectPath || null;
- if (scope === 'project') {
- if (generalCwd) {
- return { ok: true, scope: 'user', projectPath: null, wantProject: false };
- }
- if (!effectiveProjectPath) {
- return {
- ok: false,
- error: "project scope requires a real project (general chat doesn't qualify)",
- };
- }
- return { ok: true, scope: 'project', projectPath: effectiveProjectPath, wantProject: true };
- }
- if (scope === 'user') {
- return { ok: true, scope: 'user', projectPath: null, wantProject: false };
- }
- if (defaultToProjectWhenAvailable && effectiveProjectPath) {
- return { ok: true, scope: 'project', projectPath: effectiveProjectPath, wantProject: true };
- }
- return { ok: true, scope: 'user', projectPath: null, wantProject: false };
- }
- 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);
- }
- // ---------------------------------------------------------------------------
- // Core CRUD — every endpoint forwards to the gateway
- // ---------------------------------------------------------------------------
- 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 resolved = resolveRequestedScope(scope, projectPath);
- if (!resolved.ok) return res.status(400).json({ error: resolved.error });
- const result = await callGateway('skillCreate', {
- scope: resolved.scope,
- slug,
- projectKey: resolved.wantProject ? resolved.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 resolved = resolveRequestedScope(scope, projectPath);
- if (!resolved.ok) return res.status(400).json({ error: resolved.error });
- const result = await callGateway('skillImport', {
- sourcePath,
- slug,
- scope: resolved.scope,
- projectKey: resolved.wantProject ? resolved.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);
- }
- });
- // ---------------------------------------------------------------------------
- // /import-upload — multipart picker upload. Multipart bodies don't fit the
- // WS RPC, so we stage on disk and then ask the gateway to validate. The
- // final move lands in `~/.pilotdeck/skills/<slug>` or
- // `<project>/.pilotdeck/skills/<slug>` so the agent picks it up on next
- // session refresh.
- // ---------------------------------------------------------------------------
- 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 resolved = resolveRequestedScope(scope, projectPath);
- if (!resolved.ok) return res.status(400).json({ error: resolved.error });
- const root = resolved.wantProject ? projectSkillsRoot(resolved.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 {
- /* missing → fine */
- }
- 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.` });
- }
- }
- await fs.mkdir(root, { recursive: true });
- stagingDir = await fs.mkdtemp(path.join(root, '.tmp-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);
- }
- if (exists) await fs.rm(targetDir, { recursive: true, force: true });
- await moveDirectoryAcrossDevicesSafe(stagingDir, targetDir);
- stagingDir = null;
- // Round-trip through the gateway once more so the response shape
- // matches the rest of the API (skill summary populated, scope echoed).
- let skillSummary = null;
- try {
- const list = await callGateway('skillsList', {
- projectKey: resolved.wantProject ? resolved.projectPath : null,
- });
- const bucket = resolved.wantProject ? list.project : list.user;
- skillSummary = bucket.find((s) => s.slug === inferredSlug) ?? null;
- } catch {
- /* best-effort; the file is on disk regardless */
- }
- res.json({
- ok: true,
- mode: 'upload',
- scope: resolved.scope,
- slug: inferredSlug,
- skillPath: targetDir,
- skill: skillSummary,
- validation,
- });
- } catch (e) {
- if (stagingDir) {
- try {
- await fs.rm(stagingDir, { recursive: true, force: true });
- } catch {
- /* best-effort */
- }
- }
- sendGatewayError(res, e);
- }
- });
- // ---------------------------------------------------------------------------
- // ClawHub passthrough — kept here because the binary writes to disk and
- // reading it back into the gateway would just add a layer. We retarget
- // the install root to `~/.pilotdeck/skills/` (or `<project>/.pilotdeck/
- // skills/`) so installed skills end up where the agent looks.
- // ---------------------------------------------------------------------------
- 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 });
- }
- }
- // eslint-disable-next-line no-control-regex
- 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 resolved = resolveRequestedScope(scope, projectPath, {
- defaultToProjectWhenAvailable: true,
- });
- if (!resolved.ok) return res.status(400).json({ error: resolved.error });
- let workdir;
- let dir;
- if (resolved.wantProject) {
- workdir = resolved.projectPath;
- 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;
- // Pull the summary back through the gateway so descriptions reflect
- // the same frontmatter parser the agent will use.
- const list = await callGateway('skillsList', {
- projectKey: resolved.wantProject ? resolved.projectPath : null,
- });
- const bucket = resolved.wantProject ? list.project : list.user;
- skill = bucket.find((s) => s.slug === slug) ?? null;
- } catch {
- /* not installed */
- }
- const needsForce =
- !installed && !force && (stderr || stdout).match(/Use --force to install suspicious/i) !== null;
- res.json({
- ok: installed,
- slug,
- scope: resolved.scope,
- 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;
|