| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065 |
- import express from 'express';
- import { promises as fs } from 'fs';
- import path from 'path';
- import { fileURLToPath } from 'url';
- import os from 'os';
- import { execFile } from 'child_process';
- import { promisify } from 'util';
- import { CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
- import { parseFrontmatter } from '../utils/frontmatter.js';
- import { getClaudeRuntimeModelConfig, getClaudeRuntimeModelValues } from '../utils/claude-runtime-config.js';
- import { readPilotDeckConfigFile, resolveModel } from '../services/pilotdeckConfig.js';
- import { resolvePilotHome } from '../utils/pilotPaths.js';
- import { executeTurnkeySlashCommand } from '../turnkey-slash.js';
- const execFileAsync = promisify(execFile);
- const __filename = fileURLToPath(import.meta.url);
- const __dirname = path.dirname(__filename);
- const router = express.Router();
- /**
- * Slash commands curated to always appear at the top of the menu in this exact
- * order, regardless of usage history. Names that don't resolve to a real
- * on-disk command/skill or a bundled stub below are silently dropped.
- */
- const PINNED_COMMAND_NAMES = [
- '/skill_install',
- '/projects',
- '/switch-project',
- ];
- /**
- * Bundled skills registered via the skill registry in the CLI binary.
- * They are not on disk, so the directory scanners can't see them — we surface stub
- * entries so the UI menu can suggest them. The actual execution still happens
- * agent-side: typing `/projects` sends the slash text through, the proxy hands
- * it to the bundled-skill registry, and the result streams back.
- */
- const BUNDLED_SKILL_STUBS = [
- {
- name: '/projects',
- description:
- 'List every PilotDeck project visible to the TUI, gateway, and UI.',
- metadata: { type: 'bundled-skill' },
- },
- {
- name: '/switch-project',
- description:
- 'Switch the active project for the current gateway/IM conversation (no-op in TUI — those manage active project themselves).',
- metadata: { type: 'bundled-skill', argumentHint: '<project name>' },
- },
- ];
- /**
- * Recursively scan directory for command files (.md)
- * @param {string} dir - Directory to scan
- * @param {string} baseDir - Base directory for relative paths
- * @param {string} namespace - Namespace for commands (e.g., 'project', 'user')
- * @returns {Promise<Array>} Array of command objects
- */
- async function scanCommandsDirectory(dir, baseDir, namespace) {
- const commands = [];
- try {
- // Check if directory exists
- await fs.access(dir);
- const entries = await fs.readdir(dir, { withFileTypes: true });
- for (const entry of entries) {
- const fullPath = path.join(dir, entry.name);
- if (entry.isDirectory()) {
- // Recursively scan subdirectories
- const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
- commands.push(...subCommands);
- } else if (entry.isFile() && entry.name.endsWith('.md')) {
- // Parse markdown file for metadata
- try {
- const content = await fs.readFile(fullPath, 'utf8');
- const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
- // Calculate relative path from baseDir for command name
- const relativePath = path.relative(baseDir, fullPath);
- // Remove .md extension and convert to command name
- const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
- // Extract description from frontmatter or first line of content
- let description = frontmatter.description || '';
- if (!description) {
- const firstLine = commandContent.trim().split('\n')[0];
- description = firstLine.replace(/^#+\s*/, '').trim();
- }
- commands.push({
- name: commandName,
- path: fullPath,
- relativePath,
- description,
- namespace,
- metadata: frontmatter
- });
- } catch (err) {
- console.error(`Error parsing command file ${fullPath}:`, err.message);
- }
- }
- }
- } catch (err) {
- // Directory doesn't exist or can't be accessed - this is okay
- if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
- console.error(`Error scanning directory ${dir}:`, err.message);
- }
- }
- return commands;
- }
- /**
- * subdirectory `<dir>/<name>/SKILL.md` becomes the slash command `/<name>`.
- * Mirrors the upstream `loadSkillsFromSkillsDir` convention
- * so disk semantics stay aligned: directory format only, name = parent dir,
- * frontmatter parsed for description/metadata.
- *
- * @param {string} namespace - 'project' or 'user'
- * @returns {Promise<Array>} Skill command objects
- */
- async function scanSkillsDirectory(dir, namespace) {
- const skills = [];
- try {
- await fs.access(dir);
- const entries = await fs.readdir(dir, { withFileTypes: true });
- for (const entry of entries) {
- if (!entry.isDirectory() && !entry.isSymbolicLink()) {
- continue;
- }
- const skillDir = path.join(dir, entry.name);
- const skillFile = path.join(skillDir, 'SKILL.md');
- let content;
- try {
- content = await fs.readFile(skillFile, 'utf8');
- } catch (err) {
- if (err.code !== 'ENOENT') {
- console.error(`Error reading SKILL.md at ${skillFile}:`, err.message);
- }
- continue;
- }
- try {
- const { data: frontmatter, content: skillContent } = parseFrontmatter(content);
- const skillName = '/' + entry.name;
- let description = frontmatter.description || '';
- if (!description) {
- const firstLine = skillContent.trim().split('\n')[0];
- description = firstLine.replace(/^#+\s*/, '').trim();
- }
- skills.push({
- name: skillName,
- path: skillFile,
- relativePath: path.join(entry.name, 'SKILL.md'),
- description,
- namespace,
- metadata: { ...frontmatter, type: 'skill' },
- });
- } catch (err) {
- console.error(`Error parsing skill ${skillFile}:`, err.message);
- }
- }
- } catch (err) {
- if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
- console.error(`Error scanning skills directory ${dir}:`, err.message);
- }
- }
- return skills;
- }
- /**
- * Built-in commands that are always available
- */
- const builtInCommands = [
- {
- name: '/help',
- description: 'Show help documentation for PilotDeck',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/clear',
- description: 'Clear the conversation history',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/model',
- description: 'View the current AI model and available options',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/cost',
- description: 'Display token usage and cost information',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/memory',
- description: 'Open PILOTDECK.md memory file for editing',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/config',
- description: 'Open settings and configuration',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/status',
- description: 'Show system status and version information',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/rewind',
- description: 'Rewind the conversation to a previous state',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/ao',
- description: 'List, run, or inspect Always-On cron jobs and discovery plans',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/turnkey',
- description: 'Run turnkey workflow subcommands (for example: /turnkey start)',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/switch-project',
- description: 'Switch to another project by name (for example: /switch-project xhs-voxcpm)',
- namespace: 'builtin',
- metadata: { type: 'builtin' }
- },
- {
- name: '/skill_install',
- description:
- 'Install a skill from clawhub.com. Auto-targets ~/.pilotdeck/skills/<slug> in general chat and <project>/.pilotdeck/skills/<slug> when a project is active. Use --global / --project to override.',
- namespace: 'builtin',
- metadata: {
- type: 'builtin',
- argumentHint: '<slug> [--version <v>] [--force] [--global|--project] [--registry <url>]',
- },
- },
- ];
- /**
- * Built-in command handlers
- * Each handler returns { type: 'builtin', action: string, data: any }
- */
- const builtInHandlers = {
- '/help': async (args, context) => {
- const helpText = `# PilotDeck Commands
- ## Built-in Commands
- ${builtInCommands.map(cmd => `### ${cmd.name}
- ${cmd.description}
- `).join('\n')}
- ## Custom Commands
- Custom commands can be created in:
- - Project: \`.pilotdeck/commands/\` (project-specific)
- - User: \`~/.pilotdeck/commands/\` (available in all projects)
- ### Command Syntax
- - **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional
- - **File Includes**: Use \`@filename\` to include file contents
- - **Bash Commands**: Use \`!command\` to execute bash commands
- ### Examples
- \`\`\`markdown
- /mycommand arg1 arg2
- \`\`\`
- `;
- return {
- type: 'builtin',
- action: 'help',
- data: {
- content: helpText,
- format: 'markdown'
- }
- };
- },
- '/clear': async (args, context) => {
- return {
- type: 'builtin',
- action: 'clear',
- data: {
- message: 'Conversation history cleared'
- }
- };
- },
- '/model': async (args, context) => {
- const { config } = readPilotDeckConfigFile();
- const mainRef = config?.agent?.model || '';
- const resolved = resolveModel(config, mainRef, { allowMissing: true });
- const currentModel = resolved ? resolved.id : mainRef || '(not configured)';
- const providers = config?.model?.providers || {};
- const available = {};
- for (const [pid, provider] of Object.entries(providers)) {
- const models = provider.models;
- if (models && typeof models === 'object') {
- available[pid] = Object.keys(models);
- }
- }
- return {
- type: 'builtin',
- action: 'model',
- data: {
- current: {
- provider: resolved?.providerId || '',
- model: currentModel
- },
- available,
- message: args.length > 0
- ? `Switching to model: ${args[0]}`
- : `Current model: ${currentModel}`
- }
- };
- },
- '/cost': async (args, context) => {
- const tokenUsage = context?.tokenUsage || {};
- const { config: pdConfig } = readPilotDeckConfigFile();
- const mainRef = pdConfig?.agent?.model || '';
- const resolvedMain = resolveModel(pdConfig, mainRef, { allowMissing: true });
- const provider = context?.provider || resolvedMain?.providerId || 'unknown';
- const model = context?.model || (resolvedMain ? resolvedMain.id : mainRef || '(not configured)');
- const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
- const total =
- Number(
- tokenUsage.total ??
- tokenUsage.contextWindow ??
- parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
- ) || 160000;
- const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
- const inputTokensRaw =
- Number(
- tokenUsage.inputTokens ??
- tokenUsage.input ??
- tokenUsage.cumulativeInputTokens ??
- tokenUsage.promptTokens ??
- 0,
- ) || 0;
- const outputTokens =
- Number(
- tokenUsage.outputTokens ??
- tokenUsage.output ??
- tokenUsage.cumulativeOutputTokens ??
- tokenUsage.completionTokens ??
- 0,
- ) || 0;
- const cacheTokens =
- Number(
- tokenUsage.cacheReadTokens ??
- tokenUsage.cacheCreationTokens ??
- tokenUsage.cacheTokens ??
- tokenUsage.cachedTokens ??
- 0,
- ) || 0;
- // If we only have total used tokens, treat them as input for display/estimation.
- const inputTokens =
- inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
- // Rough default rates by provider (USD / 1M tokens).
- const pricingByProvider = {
- claude: { input: 3, output: 15 },
- cursor: { input: 3, output: 15 },
- codex: { input: 1.5, output: 6 },
- };
- const rates = pricingByProvider[provider] || pricingByProvider.claude;
- const inputCost = (inputTokens / 1_000_000) * rates.input;
- const outputCost = (outputTokens / 1_000_000) * rates.output;
- const totalCost = inputCost + outputCost;
- return {
- type: 'builtin',
- action: 'cost',
- data: {
- tokenUsage: {
- used,
- total,
- percentage,
- },
- cost: {
- input: inputCost.toFixed(4),
- output: outputCost.toFixed(4),
- total: totalCost.toFixed(4),
- },
- model,
- },
- };
- },
- '/status': async (args, context) => {
- const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
- let version = 'unknown';
- let packageName = 'pilotdeck';
- try {
- const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
- version = packageJson.version;
- packageName = packageJson.name;
- } catch (err) {
- console.error('Error reading package.json:', err);
- }
- const { config } = readPilotDeckConfigFile();
- const mainRef = config?.agent?.model || '';
- const resolved = resolveModel(config, mainRef, { allowMissing: true });
- const uptime = process.uptime();
- const uptimeMinutes = Math.floor(uptime / 60);
- const uptimeHours = Math.floor(uptimeMinutes / 60);
- const uptimeFormatted = uptimeHours > 0
- ? `${uptimeHours}h ${uptimeMinutes % 60}m`
- : `${uptimeMinutes}m`;
- return {
- type: 'builtin',
- action: 'status',
- data: {
- version,
- packageName,
- uptime: uptimeFormatted,
- uptimeSeconds: Math.floor(uptime),
- model: resolved ? resolved.id : mainRef || '(not configured)',
- provider: resolved?.providerId || '',
- nodeVersion: process.version,
- platform: process.platform
- }
- };
- },
- '/memory': async (args, context) => {
- const projectPath = context?.projectPath;
- if (!projectPath) {
- return {
- type: 'builtin',
- action: 'memory',
- data: {
- error: 'No project selected',
- message: 'Please select a project to access its PILOTDECK.md file'
- }
- };
- }
- const pilotDeckMdPath = path.join(projectPath, 'PILOTDECK.md');
- // Check if PILOTDECK.md exists
- let exists = false;
- try {
- await fs.access(pilotDeckMdPath);
- exists = true;
- } catch (err) {
- // File doesn't exist
- }
- return {
- type: 'builtin',
- action: 'memory',
- data: {
- path: pilotDeckMdPath,
- exists,
- message: exists
- ? `Opening PILOTDECK.md at ${pilotDeckMdPath}`
- : `PILOTDECK.md not found at ${pilotDeckMdPath}. Create it to store project-specific instructions.`
- }
- };
- },
- '/config': async (args, context) => {
- return {
- type: 'builtin',
- action: 'config',
- data: {
- message: 'Opening settings...'
- }
- };
- },
- '/rewind': async (args, context) => {
- const steps = args[0] ? parseInt(args[0]) : 1;
- if (isNaN(steps) || steps < 1) {
- return {
- type: 'builtin',
- action: 'rewind',
- data: {
- error: 'Invalid steps parameter',
- message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
- }
- };
- }
- return {
- type: 'builtin',
- action: 'rewind',
- data: {
- steps,
- message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
- }
- };
- },
- '/turnkey': async (args) => executeTurnkeySlashCommand(args),
- '/switch-project': async (args) => {
- // Trim quotes / whitespace; the rest of the project resolution (matching
- // against the user's project list, navigating, expanding the sidebar
- // entry) happens on the client where we already have the projects state.
- const requested = (args || []).join(' ').trim().replace(/^["']|["']$/g, '');
- if (!requested) {
- return {
- type: 'builtin',
- action: 'switchProject',
- data: {
- error: true,
- message: 'Usage: /switch-project <project-name>'
- }
- };
- }
- return {
- type: 'builtin',
- action: 'switchProject',
- data: {
- projectName: requested,
- message: `Switching to project: ${requested}`
- }
- };
- },
- // /skill_install — server-side clawhub install. Deterministic, no model in
- // the loop.
- //
- // Scope policy (auto-detected, override-able):
- // - In general chat (no projectPath in context) → user scope:
- // - In a project's chat (projectPath set) → project scope:
- // - Explicit override: --global forces user scope, --project forces project
- // scope (errors out if no projectPath available).
- //
- // Any positional after slug is rejected. Slug is validated against a strict
- // regex to block path traversal — execFile already prevents shell injection
- // since args are passed as an array, but we also refuse `..` defensively.
- '/skill_install': async (args, context) => {
- const argList = Array.isArray(args) ? args : [];
- let slug = null;
- let version = null;
- let force = false;
- let scopeOverride = null; // 'user' | 'project' | null
- let registry = null;
- for (let i = 0; i < argList.length; i++) {
- const token = argList[i];
- if (token === '--version' && i + 1 < argList.length) {
- version = argList[++i];
- continue;
- }
- if (token === '--force') { force = true; continue; }
- if (token === '--project') { scopeOverride = 'project'; continue; }
- if (token === '--global' || token === '--user') { scopeOverride = 'user'; continue; }
- if (token === '--registry' && i + 1 < argList.length) {
- registry = argList[++i];
- continue;
- }
- if (token.startsWith('--')) {
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: { error: true, message: `Unknown flag: ${token}` },
- };
- }
- if (slug === null) { slug = token; continue; }
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: { error: true, message: `Unexpected positional argument: ${token}` },
- };
- }
- if (!slug) {
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: {
- error: true,
- message:
- 'Usage: /skill_install <slug> [--version <ver>] [--force] [--global|--project] [--registry <url>]',
- },
- };
- }
- if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/.test(slug) || slug.includes('..')) {
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: {
- error: true,
- message: `Invalid slug "${slug}". Allowed: [a-zA-Z0-9][a-zA-Z0-9._-]{0,99}, no "..".`,
- },
- };
- }
- const projectPath = context?.projectPath || null;
- // PilotDeck's virtual "general" workspace roots at ~/.pilotdeck. It looks
- // like a real projectPath but the user's mental model is general chat →
- // user/global scope. Force user scope with --global when needed.
- const GENERAL_CWD_PATHS = [path.resolve(resolvePilotHome(process.env))];
- const isGeneralCwd =
- projectPath && GENERAL_CWD_PATHS.includes(path.resolve(projectPath));
- const effectiveProjectPath = isGeneralCwd ? null : projectPath;
- const scope = scopeOverride || (effectiveProjectPath ? 'project' : 'user');
- let workdir;
- let dir;
- if (scope === 'project') {
- if (!effectiveProjectPath) {
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: {
- error: true,
- message: isGeneralCwd
- ? '--project cannot be used in general chat (no real project active). Drop --project to install globally, or open a project chat first.'
- : '--project requires an active project (no projectPath in context).',
- },
- };
- }
- workdir = effectiveProjectPath;
- dir = path.join('.pilotdeck', 'skills');
- } else {
- workdir = path.join(os.homedir(), '.pilotdeck');
- dir = 'skills';
- }
- const installPath = path.join(workdir, dir, slug);
- // --no-input is a global flag, must come BEFORE the subcommand.
- const clawArgs = ['--no-input', '--workdir', workdir, '--dir', dir];
- if (registry) clawArgs.push('--registry', registry);
- clawArgs.push('install', slug);
- if (version) clawArgs.push('--version', version);
- if (force) clawArgs.push('--force');
- let stdout = '';
- let stderr = '';
- let runError = null;
- try {
- const result = await execFileAsync('clawhub', clawArgs, {
- timeout: 120_000,
- maxBuffer: 10 * 1024 * 1024,
- });
- stdout = result.stdout || '';
- stderr = result.stderr || '';
- } catch (e) {
- runError = e;
- stdout = e.stdout || '';
- stderr = e.stderr || '';
- }
- let installed = false;
- let skillMeta = null;
- try {
- await fs.access(path.join(installPath, 'SKILL.md'));
- installed = true;
- try {
- const content = await fs.readFile(path.join(installPath, 'SKILL.md'), 'utf8');
- const { data: fm } = parseFrontmatter(content);
- skillMeta = {
- name: fm.name || slug,
- description: fm.description || '',
- version: fm.version || null,
- };
- } catch {
- /* SKILL.md exists but unreadable/unparseable — keep installed=true */
- }
- } catch {
- /* SKILL.md missing — installed stays false */
- }
- if (runError && runError.code === 'ENOENT') {
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: {
- error: true,
- message:
- 'clawhub CLI not found in PATH. Install it with `npm install -g clawhub`, then retry.',
- },
- };
- }
- // Detect "suspicious skill, --force required" — clawhub's --no-input mode
- // refuses VirusTotal-flagged skills without explicit consent. Surface a
- // copy-pasteable retry command instead of burying the hint in stderr.
- const needsForce =
- !installed &&
- !force &&
- (stderr || stdout).match(/Use --force to install suspicious/i) !== null;
- let retryCommand = null;
- if (needsForce) {
- const overrideFlag =
- scopeOverride === 'user'
- ? ' --global'
- : scopeOverride === 'project'
- ? ' --project'
- : '';
- const versionFlag = version ? ` --version ${version}` : '';
- const registryFlag = registry ? ` --registry ${registry}` : '';
- retryCommand = `/skill_install ${slug}${overrideFlag} --force${versionFlag}${registryFlag}`;
- }
- return {
- type: 'builtin',
- action: 'skillInstall',
- data: {
- slug,
- version: version || null,
- scope,
- scopeAutoDetected: scopeOverride === null,
- projectPath: effectiveProjectPath,
- rawProjectPath: projectPath,
- isGeneralCwd,
- installPath,
- installed,
- skillMeta,
- stdout: stdout.trim(),
- stderr: stderr.trim(),
- exitCode: runError ? (runError.code === undefined ? 1 : runError.code) : 0,
- errorMessage: runError ? (runError.shortMessage || runError.message) : null,
- needsForce,
- retryCommand,
- },
- };
- },
- };
- /**
- * POST /api/commands/list
- * List all available commands from project and user directories
- *
- * Discovery layout:
- * - Built-in commands: hardcoded in this file (handled by builtInHandlers).
- * - Bundled skills: hardcoded stubs (BUNDLED_SKILL_STUBS) — actual handlers
- * live in the CLI binary; we only surface them so the UI menu shows them.
- * - On-disk commands: `.pilotdeck/commands/**\/*.md` (project + user).
- *
- * Dedup: when the same `/<name>` exists in multiple places, project wins over
- * user, and `commands/` wins over `skills/` (first-seen preference).
- * Bundled stubs only surface when no on-disk override exists.
- *
- * Pinning: PINNED_COMMAND_NAMES are reassigned `namespace: 'pinned'` so the
- * frontend menu pulls them into a curated top group, in fixed order.
- */
- router.post('/list', async (req, res) => {
- try {
- const { projectPath } = req.body;
- const homeDir = os.homedir();
- const customCommandSources = [];
- if (projectPath) {
- const projectCommandsDir = path.join(projectPath, '.pilotdeck', 'commands');
- const projectSkillsDir = path.join(projectPath, '.pilotdeck', 'skills');
- const [projectCommands, projectSkills] = await Promise.all([
- scanCommandsDirectory(projectCommandsDir, projectCommandsDir, 'project'),
- scanSkillsDirectory(projectSkillsDir, 'project'),
- ]);
- customCommandSources.push(...projectCommands, ...projectSkills);
- }
- const userCommandsDir = path.join(homeDir, '.pilotdeck', 'commands');
- const userSkillsDir = path.join(homeDir, '.pilotdeck', 'skills');
- const [userCommands, userSkills] = await Promise.all([
- scanCommandsDirectory(userCommandsDir, userCommandsDir, 'user'),
- scanSkillsDirectory(userSkillsDir, 'user'),
- ]);
- customCommandSources.push(...userCommands, ...userSkills);
- // Track every name we've committed so far to a single namespace. Built-in
- // names take precedence over disk customs and bundled stubs (their server-
- // side handlers in `builtInHandlers` are authoritative).
- const seenNames = new Set(builtInCommands.map((cmd) => cmd.name));
- const dedupedCustom = [];
- for (const cmd of customCommandSources) {
- if (seenNames.has(cmd.name)) continue;
- seenNames.add(cmd.name);
- dedupedCustom.push(cmd);
- }
- const builtInsWithBundled = [...builtInCommands];
- for (const stub of BUNDLED_SKILL_STUBS) {
- if (seenNames.has(stub.name)) continue;
- builtInsWithBundled.push({
- ...stub,
- namespace: 'builtin',
- });
- seenNames.add(stub.name);
- }
- dedupedCustom.sort((a, b) => a.name.localeCompare(b.name));
- const pinnedSet = new Set(PINNED_COMMAND_NAMES);
- const promote = (cmd) =>
- pinnedSet.has(cmd.name) ? { ...cmd, namespace: 'pinned' } : cmd;
- const builtIn = builtInsWithBundled.map(promote);
- const custom = dedupedCustom.map(promote);
- const indexByName = new Map();
- for (const cmd of [...builtIn, ...custom]) {
- if (!indexByName.has(cmd.name)) indexByName.set(cmd.name, cmd);
- }
- const pinnedOrdered = PINNED_COMMAND_NAMES
- .map((name) => indexByName.get(name))
- .filter(Boolean);
- res.json({
- builtIn,
- custom,
- pinned: pinnedOrdered,
- count: builtIn.length + custom.length,
- });
- } catch (error) {
- console.error('Error listing commands:', error);
- res.status(500).json({
- error: 'Failed to list commands',
- message: error.message,
- });
- }
- });
- /**
- * POST /api/commands/load
- * Load a specific command file and return its content and metadata
- */
- router.post('/load', async (req, res) => {
- try {
- const { commandPath } = req.body;
- if (!commandPath) {
- return res.status(400).json({
- error: 'Command path is required'
- });
- }
- // Security: Prevent path traversal. Allow paths under any
- const resolvedPath = path.resolve(commandPath);
- const inHome = resolvedPath.startsWith(path.resolve(os.homedir()));
- const inPilotdeckSubdir = /\.pilotdeck\/(commands|skills)\//.test(resolvedPath);
- if (!inHome && !inPilotdeckSubdir) {
- return res.status(403).json({
- error: 'Access denied',
- message: 'Command must be in a .pilotdeck/commands or .pilotdeck/skills directory'
- });
- }
- // Read and parse the command file
- const content = await fs.readFile(commandPath, 'utf8');
- const { data: metadata, content: commandContent } = parseFrontmatter(content);
- res.json({
- path: commandPath,
- metadata,
- content: commandContent
- });
- } catch (error) {
- if (error.code === 'ENOENT') {
- return res.status(404).json({
- error: 'Command not found',
- message: `Command file not found: ${req.body.commandPath}`
- });
- }
- console.error('Error loading command:', error);
- res.status(500).json({
- error: 'Failed to load command',
- message: error.message
- });
- }
- });
- /**
- * POST /api/commands/execute
- * Execute a command with argument replacement
- * This endpoint prepares the command content but doesn't execute bash commands yet
- * (that will be handled in the command parser utility)
- */
- router.post('/execute', async (req, res) => {
- try {
- const { commandName, commandPath, args = [], context = {} } = req.body;
- if (!commandName) {
- return res.status(400).json({
- error: 'Command name is required'
- });
- }
- // Handle built-in commands
- const handler = builtInHandlers[commandName];
- if (handler) {
- try {
- const result = await handler(args, context);
- return res.json({
- ...result,
- command: commandName
- });
- } catch (error) {
- console.error(`Error executing built-in command ${commandName}:`, error);
- return res.status(500).json({
- error: 'Command execution failed',
- message: error.message,
- command: commandName
- });
- }
- }
- // Bundled-skill stubs (e.g. /projects, /add-project) have no on-disk
- // file — the CLI's `registerBundledSkill` registry handles the
- // actual execution. Send the raw `/<name> <args>` text back as a
- // passthrough so the frontend submits it as normal user input; the proxy's
- // slash parser then routes to the bundled skill.
- const isBundledStub = BUNDLED_SKILL_STUBS.some(
- (stub) => stub.name === commandName,
- );
- if (isBundledStub) {
- const argsString = args.join(' ').trim();
- const passthroughContent = argsString
- ? `${commandName} ${argsString}`
- : commandName;
- return res.json({
- type: 'custom',
- command: commandName,
- content: passthroughContent,
- metadata: { type: 'bundled-skill', passthrough: true },
- hasFileIncludes: false,
- hasBashCommands: false,
- });
- }
- // server-side and submitted as raw user input — that would dump the whole
- // SKILL.md body into chat. Instead, passthrough the slash text so the
- // proxy's slash parser invokes SkillTool with the procedural body.
- if (commandPath && /\/\.pilotdeck\/skills\/[^/]+\/SKILL\.md$/i.test(commandPath)) {
- const argsString = args.join(' ').trim();
- const passthroughContent = argsString
- ? `${commandName} ${argsString}`
- : commandName;
- return res.json({
- type: 'custom',
- command: commandName,
- content: passthroughContent,
- metadata: { type: 'skill', passthrough: true },
- hasFileIncludes: false,
- hasBashCommands: false,
- });
- }
- if (!commandPath) {
- return res.status(400).json({
- error: 'Command path is required for custom commands'
- });
- }
- // Load command content
- // Security: validate commandPath is within allowed directories.
- {
- const resolvedPath = path.resolve(commandPath);
- const allowedBases = [
- path.resolve(path.join(os.homedir(), '.pilotdeck', 'commands')),
- path.resolve(path.join(os.homedir(), '.pilotdeck', 'skills')),
- ];
- if (context?.projectPath) {
- allowedBases.push(
- path.resolve(path.join(context.projectPath, '.pilotdeck', 'commands')),
- path.resolve(path.join(context.projectPath, '.pilotdeck', 'skills')),
- );
- }
- const isUnder = (base) => {
- const rel = path.relative(base, resolvedPath);
- return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
- };
- if (!allowedBases.some(isUnder)) {
- return res.status(403).json({
- error: 'Access denied',
- message: 'Command must be in a .pilotdeck/commands or .pilotdeck/skills directory'
- });
- }
- }
- const content = await fs.readFile(commandPath, 'utf8');
- const { data: metadata, content: commandContent } = parseFrontmatter(content);
- // Basic argument replacement (will be enhanced in command parser utility)
- let processedContent = commandContent;
- // Replace $ARGUMENTS with all arguments joined
- const argsString = args.join(' ');
- processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
- // Replace $1, $2, etc. with positional arguments
- args.forEach((arg, index) => {
- const placeholder = `$${index + 1}`;
- processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
- });
- res.json({
- type: 'custom',
- command: commandName,
- content: processedContent,
- metadata,
- hasFileIncludes: processedContent.includes('@'),
- hasBashCommands: processedContent.includes('!')
- });
- } catch (error) {
- if (error.code === 'ENOENT') {
- return res.status(404).json({
- error: 'Command not found',
- message: `Command file not found: ${req.body.commandPath}`
- });
- }
- console.error('Error executing command:', error);
- res.status(500).json({
- error: 'Failed to execute command',
- message: error.message
- });
- }
- });
- export default router;
|