commands.js 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065
  1. import express from 'express';
  2. import { promises as fs } from 'fs';
  3. import path from 'path';
  4. import { fileURLToPath } from 'url';
  5. import os from 'os';
  6. import { execFile } from 'child_process';
  7. import { promisify } from 'util';
  8. import { CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
  9. import { parseFrontmatter } from '../utils/frontmatter.js';
  10. import { getClaudeRuntimeModelConfig, getClaudeRuntimeModelValues } from '../utils/claude-runtime-config.js';
  11. import { readPilotDeckConfigFile, resolveModel } from '../services/pilotdeckConfig.js';
  12. import { resolvePilotHome } from '../utils/pilotPaths.js';
  13. import { executeTurnkeySlashCommand } from '../turnkey-slash.js';
  14. const execFileAsync = promisify(execFile);
  15. const __filename = fileURLToPath(import.meta.url);
  16. const __dirname = path.dirname(__filename);
  17. const router = express.Router();
  18. /**
  19. * Slash commands curated to always appear at the top of the menu in this exact
  20. * order, regardless of usage history. Names that don't resolve to a real
  21. * on-disk command/skill or a bundled stub below are silently dropped.
  22. */
  23. const PINNED_COMMAND_NAMES = [
  24. '/skill_install',
  25. '/projects',
  26. '/switch-project',
  27. ];
  28. /**
  29. * Bundled skills registered via the skill registry in the CLI binary.
  30. * They are not on disk, so the directory scanners can't see them — we surface stub
  31. * entries so the UI menu can suggest them. The actual execution still happens
  32. * agent-side: typing `/projects` sends the slash text through, the proxy hands
  33. * it to the bundled-skill registry, and the result streams back.
  34. */
  35. const BUNDLED_SKILL_STUBS = [
  36. {
  37. name: '/projects',
  38. description:
  39. 'List every PilotDeck project visible to the TUI, gateway, and UI.',
  40. metadata: { type: 'bundled-skill' },
  41. },
  42. {
  43. name: '/switch-project',
  44. description:
  45. 'Switch the active project for the current gateway/IM conversation (no-op in TUI — those manage active project themselves).',
  46. metadata: { type: 'bundled-skill', argumentHint: '<project name>' },
  47. },
  48. ];
  49. /**
  50. * Recursively scan directory for command files (.md)
  51. * @param {string} dir - Directory to scan
  52. * @param {string} baseDir - Base directory for relative paths
  53. * @param {string} namespace - Namespace for commands (e.g., 'project', 'user')
  54. * @returns {Promise<Array>} Array of command objects
  55. */
  56. async function scanCommandsDirectory(dir, baseDir, namespace) {
  57. const commands = [];
  58. try {
  59. // Check if directory exists
  60. await fs.access(dir);
  61. const entries = await fs.readdir(dir, { withFileTypes: true });
  62. for (const entry of entries) {
  63. const fullPath = path.join(dir, entry.name);
  64. if (entry.isDirectory()) {
  65. // Recursively scan subdirectories
  66. const subCommands = await scanCommandsDirectory(fullPath, baseDir, namespace);
  67. commands.push(...subCommands);
  68. } else if (entry.isFile() && entry.name.endsWith('.md')) {
  69. // Parse markdown file for metadata
  70. try {
  71. const content = await fs.readFile(fullPath, 'utf8');
  72. const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
  73. // Calculate relative path from baseDir for command name
  74. const relativePath = path.relative(baseDir, fullPath);
  75. // Remove .md extension and convert to command name
  76. const commandName = '/' + relativePath.replace(/\.md$/, '').replace(/\\/g, '/');
  77. // Extract description from frontmatter or first line of content
  78. let description = frontmatter.description || '';
  79. if (!description) {
  80. const firstLine = commandContent.trim().split('\n')[0];
  81. description = firstLine.replace(/^#+\s*/, '').trim();
  82. }
  83. commands.push({
  84. name: commandName,
  85. path: fullPath,
  86. relativePath,
  87. description,
  88. namespace,
  89. metadata: frontmatter
  90. });
  91. } catch (err) {
  92. console.error(`Error parsing command file ${fullPath}:`, err.message);
  93. }
  94. }
  95. }
  96. } catch (err) {
  97. // Directory doesn't exist or can't be accessed - this is okay
  98. if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
  99. console.error(`Error scanning directory ${dir}:`, err.message);
  100. }
  101. }
  102. return commands;
  103. }
  104. /**
  105. * subdirectory `<dir>/<name>/SKILL.md` becomes the slash command `/<name>`.
  106. * Mirrors the upstream `loadSkillsFromSkillsDir` convention
  107. * so disk semantics stay aligned: directory format only, name = parent dir,
  108. * frontmatter parsed for description/metadata.
  109. *
  110. * @param {string} namespace - 'project' or 'user'
  111. * @returns {Promise<Array>} Skill command objects
  112. */
  113. async function scanSkillsDirectory(dir, namespace) {
  114. const skills = [];
  115. try {
  116. await fs.access(dir);
  117. const entries = await fs.readdir(dir, { withFileTypes: true });
  118. for (const entry of entries) {
  119. if (!entry.isDirectory() && !entry.isSymbolicLink()) {
  120. continue;
  121. }
  122. const skillDir = path.join(dir, entry.name);
  123. const skillFile = path.join(skillDir, 'SKILL.md');
  124. let content;
  125. try {
  126. content = await fs.readFile(skillFile, 'utf8');
  127. } catch (err) {
  128. if (err.code !== 'ENOENT') {
  129. console.error(`Error reading SKILL.md at ${skillFile}:`, err.message);
  130. }
  131. continue;
  132. }
  133. try {
  134. const { data: frontmatter, content: skillContent } = parseFrontmatter(content);
  135. const skillName = '/' + entry.name;
  136. let description = frontmatter.description || '';
  137. if (!description) {
  138. const firstLine = skillContent.trim().split('\n')[0];
  139. description = firstLine.replace(/^#+\s*/, '').trim();
  140. }
  141. skills.push({
  142. name: skillName,
  143. path: skillFile,
  144. relativePath: path.join(entry.name, 'SKILL.md'),
  145. description,
  146. namespace,
  147. metadata: { ...frontmatter, type: 'skill' },
  148. });
  149. } catch (err) {
  150. console.error(`Error parsing skill ${skillFile}:`, err.message);
  151. }
  152. }
  153. } catch (err) {
  154. if (err.code !== 'ENOENT' && err.code !== 'EACCES') {
  155. console.error(`Error scanning skills directory ${dir}:`, err.message);
  156. }
  157. }
  158. return skills;
  159. }
  160. /**
  161. * Built-in commands that are always available
  162. */
  163. const builtInCommands = [
  164. {
  165. name: '/help',
  166. description: 'Show help documentation for PilotDeck',
  167. namespace: 'builtin',
  168. metadata: { type: 'builtin' }
  169. },
  170. {
  171. name: '/clear',
  172. description: 'Clear the conversation history',
  173. namespace: 'builtin',
  174. metadata: { type: 'builtin' }
  175. },
  176. {
  177. name: '/model',
  178. description: 'View the current AI model and available options',
  179. namespace: 'builtin',
  180. metadata: { type: 'builtin' }
  181. },
  182. {
  183. name: '/cost',
  184. description: 'Display token usage and cost information',
  185. namespace: 'builtin',
  186. metadata: { type: 'builtin' }
  187. },
  188. {
  189. name: '/memory',
  190. description: 'Open PILOTDECK.md memory file for editing',
  191. namespace: 'builtin',
  192. metadata: { type: 'builtin' }
  193. },
  194. {
  195. name: '/config',
  196. description: 'Open settings and configuration',
  197. namespace: 'builtin',
  198. metadata: { type: 'builtin' }
  199. },
  200. {
  201. name: '/status',
  202. description: 'Show system status and version information',
  203. namespace: 'builtin',
  204. metadata: { type: 'builtin' }
  205. },
  206. {
  207. name: '/rewind',
  208. description: 'Rewind the conversation to a previous state',
  209. namespace: 'builtin',
  210. metadata: { type: 'builtin' }
  211. },
  212. {
  213. name: '/ao',
  214. description: 'List, run, or inspect Always-On cron jobs and discovery plans',
  215. namespace: 'builtin',
  216. metadata: { type: 'builtin' }
  217. },
  218. {
  219. name: '/turnkey',
  220. description: 'Run turnkey workflow subcommands (for example: /turnkey start)',
  221. namespace: 'builtin',
  222. metadata: { type: 'builtin' }
  223. },
  224. {
  225. name: '/switch-project',
  226. description: 'Switch to another project by name (for example: /switch-project xhs-voxcpm)',
  227. namespace: 'builtin',
  228. metadata: { type: 'builtin' }
  229. },
  230. {
  231. name: '/skill_install',
  232. description:
  233. '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.',
  234. namespace: 'builtin',
  235. metadata: {
  236. type: 'builtin',
  237. argumentHint: '<slug> [--version <v>] [--force] [--global|--project] [--registry <url>]',
  238. },
  239. },
  240. ];
  241. /**
  242. * Built-in command handlers
  243. * Each handler returns { type: 'builtin', action: string, data: any }
  244. */
  245. const builtInHandlers = {
  246. '/help': async (args, context) => {
  247. const helpText = `# PilotDeck Commands
  248. ## Built-in Commands
  249. ${builtInCommands.map(cmd => `### ${cmd.name}
  250. ${cmd.description}
  251. `).join('\n')}
  252. ## Custom Commands
  253. Custom commands can be created in:
  254. - Project: \`.pilotdeck/commands/\` (project-specific)
  255. - User: \`~/.pilotdeck/commands/\` (available in all projects)
  256. ### Command Syntax
  257. - **Arguments**: Use \`$ARGUMENTS\` for all args or \`$1\`, \`$2\`, etc. for positional
  258. - **File Includes**: Use \`@filename\` to include file contents
  259. - **Bash Commands**: Use \`!command\` to execute bash commands
  260. ### Examples
  261. \`\`\`markdown
  262. /mycommand arg1 arg2
  263. \`\`\`
  264. `;
  265. return {
  266. type: 'builtin',
  267. action: 'help',
  268. data: {
  269. content: helpText,
  270. format: 'markdown'
  271. }
  272. };
  273. },
  274. '/clear': async (args, context) => {
  275. return {
  276. type: 'builtin',
  277. action: 'clear',
  278. data: {
  279. message: 'Conversation history cleared'
  280. }
  281. };
  282. },
  283. '/model': async (args, context) => {
  284. const { config } = readPilotDeckConfigFile();
  285. const mainRef = config?.agent?.model || '';
  286. const resolved = resolveModel(config, mainRef, { allowMissing: true });
  287. const currentModel = resolved ? resolved.id : mainRef || '(not configured)';
  288. const providers = config?.model?.providers || {};
  289. const available = {};
  290. for (const [pid, provider] of Object.entries(providers)) {
  291. const models = provider.models;
  292. if (models && typeof models === 'object') {
  293. available[pid] = Object.keys(models);
  294. }
  295. }
  296. return {
  297. type: 'builtin',
  298. action: 'model',
  299. data: {
  300. current: {
  301. provider: resolved?.providerId || '',
  302. model: currentModel
  303. },
  304. available,
  305. message: args.length > 0
  306. ? `Switching to model: ${args[0]}`
  307. : `Current model: ${currentModel}`
  308. }
  309. };
  310. },
  311. '/cost': async (args, context) => {
  312. const tokenUsage = context?.tokenUsage || {};
  313. const { config: pdConfig } = readPilotDeckConfigFile();
  314. const mainRef = pdConfig?.agent?.model || '';
  315. const resolvedMain = resolveModel(pdConfig, mainRef, { allowMissing: true });
  316. const provider = context?.provider || resolvedMain?.providerId || 'unknown';
  317. const model = context?.model || (resolvedMain ? resolvedMain.id : mainRef || '(not configured)');
  318. const used = Number(tokenUsage.used ?? tokenUsage.totalUsed ?? tokenUsage.total_tokens ?? 0) || 0;
  319. const total =
  320. Number(
  321. tokenUsage.total ??
  322. tokenUsage.contextWindow ??
  323. parseInt(process.env.CONTEXT_WINDOW || '160000', 10),
  324. ) || 160000;
  325. const percentage = total > 0 ? Number(((used / total) * 100).toFixed(1)) : 0;
  326. const inputTokensRaw =
  327. Number(
  328. tokenUsage.inputTokens ??
  329. tokenUsage.input ??
  330. tokenUsage.cumulativeInputTokens ??
  331. tokenUsage.promptTokens ??
  332. 0,
  333. ) || 0;
  334. const outputTokens =
  335. Number(
  336. tokenUsage.outputTokens ??
  337. tokenUsage.output ??
  338. tokenUsage.cumulativeOutputTokens ??
  339. tokenUsage.completionTokens ??
  340. 0,
  341. ) || 0;
  342. const cacheTokens =
  343. Number(
  344. tokenUsage.cacheReadTokens ??
  345. tokenUsage.cacheCreationTokens ??
  346. tokenUsage.cacheTokens ??
  347. tokenUsage.cachedTokens ??
  348. 0,
  349. ) || 0;
  350. // If we only have total used tokens, treat them as input for display/estimation.
  351. const inputTokens =
  352. inputTokensRaw > 0 || outputTokens > 0 || cacheTokens > 0 ? inputTokensRaw + cacheTokens : used;
  353. // Rough default rates by provider (USD / 1M tokens).
  354. const pricingByProvider = {
  355. claude: { input: 3, output: 15 },
  356. cursor: { input: 3, output: 15 },
  357. codex: { input: 1.5, output: 6 },
  358. };
  359. const rates = pricingByProvider[provider] || pricingByProvider.claude;
  360. const inputCost = (inputTokens / 1_000_000) * rates.input;
  361. const outputCost = (outputTokens / 1_000_000) * rates.output;
  362. const totalCost = inputCost + outputCost;
  363. return {
  364. type: 'builtin',
  365. action: 'cost',
  366. data: {
  367. tokenUsage: {
  368. used,
  369. total,
  370. percentage,
  371. },
  372. cost: {
  373. input: inputCost.toFixed(4),
  374. output: outputCost.toFixed(4),
  375. total: totalCost.toFixed(4),
  376. },
  377. model,
  378. },
  379. };
  380. },
  381. '/status': async (args, context) => {
  382. const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
  383. let version = 'unknown';
  384. let packageName = 'pilotdeck';
  385. try {
  386. const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
  387. version = packageJson.version;
  388. packageName = packageJson.name;
  389. } catch (err) {
  390. console.error('Error reading package.json:', err);
  391. }
  392. const { config } = readPilotDeckConfigFile();
  393. const mainRef = config?.agent?.model || '';
  394. const resolved = resolveModel(config, mainRef, { allowMissing: true });
  395. const uptime = process.uptime();
  396. const uptimeMinutes = Math.floor(uptime / 60);
  397. const uptimeHours = Math.floor(uptimeMinutes / 60);
  398. const uptimeFormatted = uptimeHours > 0
  399. ? `${uptimeHours}h ${uptimeMinutes % 60}m`
  400. : `${uptimeMinutes}m`;
  401. return {
  402. type: 'builtin',
  403. action: 'status',
  404. data: {
  405. version,
  406. packageName,
  407. uptime: uptimeFormatted,
  408. uptimeSeconds: Math.floor(uptime),
  409. model: resolved ? resolved.id : mainRef || '(not configured)',
  410. provider: resolved?.providerId || '',
  411. nodeVersion: process.version,
  412. platform: process.platform
  413. }
  414. };
  415. },
  416. '/memory': async (args, context) => {
  417. const projectPath = context?.projectPath;
  418. if (!projectPath) {
  419. return {
  420. type: 'builtin',
  421. action: 'memory',
  422. data: {
  423. error: 'No project selected',
  424. message: 'Please select a project to access its PILOTDECK.md file'
  425. }
  426. };
  427. }
  428. const pilotDeckMdPath = path.join(projectPath, 'PILOTDECK.md');
  429. // Check if PILOTDECK.md exists
  430. let exists = false;
  431. try {
  432. await fs.access(pilotDeckMdPath);
  433. exists = true;
  434. } catch (err) {
  435. // File doesn't exist
  436. }
  437. return {
  438. type: 'builtin',
  439. action: 'memory',
  440. data: {
  441. path: pilotDeckMdPath,
  442. exists,
  443. message: exists
  444. ? `Opening PILOTDECK.md at ${pilotDeckMdPath}`
  445. : `PILOTDECK.md not found at ${pilotDeckMdPath}. Create it to store project-specific instructions.`
  446. }
  447. };
  448. },
  449. '/config': async (args, context) => {
  450. return {
  451. type: 'builtin',
  452. action: 'config',
  453. data: {
  454. message: 'Opening settings...'
  455. }
  456. };
  457. },
  458. '/rewind': async (args, context) => {
  459. const steps = args[0] ? parseInt(args[0]) : 1;
  460. if (isNaN(steps) || steps < 1) {
  461. return {
  462. type: 'builtin',
  463. action: 'rewind',
  464. data: {
  465. error: 'Invalid steps parameter',
  466. message: 'Usage: /rewind [number] - Rewind conversation by N steps (default: 1)'
  467. }
  468. };
  469. }
  470. return {
  471. type: 'builtin',
  472. action: 'rewind',
  473. data: {
  474. steps,
  475. message: `Rewinding conversation by ${steps} step${steps > 1 ? 's' : ''}...`
  476. }
  477. };
  478. },
  479. '/turnkey': async (args) => executeTurnkeySlashCommand(args),
  480. '/switch-project': async (args) => {
  481. // Trim quotes / whitespace; the rest of the project resolution (matching
  482. // against the user's project list, navigating, expanding the sidebar
  483. // entry) happens on the client where we already have the projects state.
  484. const requested = (args || []).join(' ').trim().replace(/^["']|["']$/g, '');
  485. if (!requested) {
  486. return {
  487. type: 'builtin',
  488. action: 'switchProject',
  489. data: {
  490. error: true,
  491. message: 'Usage: /switch-project <project-name>'
  492. }
  493. };
  494. }
  495. return {
  496. type: 'builtin',
  497. action: 'switchProject',
  498. data: {
  499. projectName: requested,
  500. message: `Switching to project: ${requested}`
  501. }
  502. };
  503. },
  504. // /skill_install — server-side clawhub install. Deterministic, no model in
  505. // the loop.
  506. //
  507. // Scope policy (auto-detected, override-able):
  508. // - In general chat (no projectPath in context) → user scope:
  509. // - In a project's chat (projectPath set) → project scope:
  510. // - Explicit override: --global forces user scope, --project forces project
  511. // scope (errors out if no projectPath available).
  512. //
  513. // Any positional after slug is rejected. Slug is validated against a strict
  514. // regex to block path traversal — execFile already prevents shell injection
  515. // since args are passed as an array, but we also refuse `..` defensively.
  516. '/skill_install': async (args, context) => {
  517. const argList = Array.isArray(args) ? args : [];
  518. let slug = null;
  519. let version = null;
  520. let force = false;
  521. let scopeOverride = null; // 'user' | 'project' | null
  522. let registry = null;
  523. for (let i = 0; i < argList.length; i++) {
  524. const token = argList[i];
  525. if (token === '--version' && i + 1 < argList.length) {
  526. version = argList[++i];
  527. continue;
  528. }
  529. if (token === '--force') { force = true; continue; }
  530. if (token === '--project') { scopeOverride = 'project'; continue; }
  531. if (token === '--global' || token === '--user') { scopeOverride = 'user'; continue; }
  532. if (token === '--registry' && i + 1 < argList.length) {
  533. registry = argList[++i];
  534. continue;
  535. }
  536. if (token.startsWith('--')) {
  537. return {
  538. type: 'builtin',
  539. action: 'skillInstall',
  540. data: { error: true, message: `Unknown flag: ${token}` },
  541. };
  542. }
  543. if (slug === null) { slug = token; continue; }
  544. return {
  545. type: 'builtin',
  546. action: 'skillInstall',
  547. data: { error: true, message: `Unexpected positional argument: ${token}` },
  548. };
  549. }
  550. if (!slug) {
  551. return {
  552. type: 'builtin',
  553. action: 'skillInstall',
  554. data: {
  555. error: true,
  556. message:
  557. 'Usage: /skill_install <slug> [--version <ver>] [--force] [--global|--project] [--registry <url>]',
  558. },
  559. };
  560. }
  561. if (!/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,99}$/.test(slug) || slug.includes('..')) {
  562. return {
  563. type: 'builtin',
  564. action: 'skillInstall',
  565. data: {
  566. error: true,
  567. message: `Invalid slug "${slug}". Allowed: [a-zA-Z0-9][a-zA-Z0-9._-]{0,99}, no "..".`,
  568. },
  569. };
  570. }
  571. const projectPath = context?.projectPath || null;
  572. // PilotDeck's virtual "general" workspace roots at ~/.pilotdeck. It looks
  573. // like a real projectPath but the user's mental model is general chat →
  574. // user/global scope. Force user scope with --global when needed.
  575. const GENERAL_CWD_PATHS = [path.resolve(resolvePilotHome(process.env))];
  576. const isGeneralCwd =
  577. projectPath && GENERAL_CWD_PATHS.includes(path.resolve(projectPath));
  578. const effectiveProjectPath = isGeneralCwd ? null : projectPath;
  579. const scope = scopeOverride || (effectiveProjectPath ? 'project' : 'user');
  580. let workdir;
  581. let dir;
  582. if (scope === 'project') {
  583. if (!effectiveProjectPath) {
  584. return {
  585. type: 'builtin',
  586. action: 'skillInstall',
  587. data: {
  588. error: true,
  589. message: isGeneralCwd
  590. ? '--project cannot be used in general chat (no real project active). Drop --project to install globally, or open a project chat first.'
  591. : '--project requires an active project (no projectPath in context).',
  592. },
  593. };
  594. }
  595. workdir = effectiveProjectPath;
  596. dir = path.join('.pilotdeck', 'skills');
  597. } else {
  598. workdir = path.join(os.homedir(), '.pilotdeck');
  599. dir = 'skills';
  600. }
  601. const installPath = path.join(workdir, dir, slug);
  602. // --no-input is a global flag, must come BEFORE the subcommand.
  603. const clawArgs = ['--no-input', '--workdir', workdir, '--dir', dir];
  604. if (registry) clawArgs.push('--registry', registry);
  605. clawArgs.push('install', slug);
  606. if (version) clawArgs.push('--version', version);
  607. if (force) clawArgs.push('--force');
  608. let stdout = '';
  609. let stderr = '';
  610. let runError = null;
  611. try {
  612. const result = await execFileAsync('clawhub', clawArgs, {
  613. timeout: 120_000,
  614. maxBuffer: 10 * 1024 * 1024,
  615. });
  616. stdout = result.stdout || '';
  617. stderr = result.stderr || '';
  618. } catch (e) {
  619. runError = e;
  620. stdout = e.stdout || '';
  621. stderr = e.stderr || '';
  622. }
  623. let installed = false;
  624. let skillMeta = null;
  625. try {
  626. await fs.access(path.join(installPath, 'SKILL.md'));
  627. installed = true;
  628. try {
  629. const content = await fs.readFile(path.join(installPath, 'SKILL.md'), 'utf8');
  630. const { data: fm } = parseFrontmatter(content);
  631. skillMeta = {
  632. name: fm.name || slug,
  633. description: fm.description || '',
  634. version: fm.version || null,
  635. };
  636. } catch {
  637. /* SKILL.md exists but unreadable/unparseable — keep installed=true */
  638. }
  639. } catch {
  640. /* SKILL.md missing — installed stays false */
  641. }
  642. if (runError && runError.code === 'ENOENT') {
  643. return {
  644. type: 'builtin',
  645. action: 'skillInstall',
  646. data: {
  647. error: true,
  648. message:
  649. 'clawhub CLI not found in PATH. Install it with `npm install -g clawhub`, then retry.',
  650. },
  651. };
  652. }
  653. // Detect "suspicious skill, --force required" — clawhub's --no-input mode
  654. // refuses VirusTotal-flagged skills without explicit consent. Surface a
  655. // copy-pasteable retry command instead of burying the hint in stderr.
  656. const needsForce =
  657. !installed &&
  658. !force &&
  659. (stderr || stdout).match(/Use --force to install suspicious/i) !== null;
  660. let retryCommand = null;
  661. if (needsForce) {
  662. const overrideFlag =
  663. scopeOverride === 'user'
  664. ? ' --global'
  665. : scopeOverride === 'project'
  666. ? ' --project'
  667. : '';
  668. const versionFlag = version ? ` --version ${version}` : '';
  669. const registryFlag = registry ? ` --registry ${registry}` : '';
  670. retryCommand = `/skill_install ${slug}${overrideFlag} --force${versionFlag}${registryFlag}`;
  671. }
  672. return {
  673. type: 'builtin',
  674. action: 'skillInstall',
  675. data: {
  676. slug,
  677. version: version || null,
  678. scope,
  679. scopeAutoDetected: scopeOverride === null,
  680. projectPath: effectiveProjectPath,
  681. rawProjectPath: projectPath,
  682. isGeneralCwd,
  683. installPath,
  684. installed,
  685. skillMeta,
  686. stdout: stdout.trim(),
  687. stderr: stderr.trim(),
  688. exitCode: runError ? (runError.code === undefined ? 1 : runError.code) : 0,
  689. errorMessage: runError ? (runError.shortMessage || runError.message) : null,
  690. needsForce,
  691. retryCommand,
  692. },
  693. };
  694. },
  695. };
  696. /**
  697. * POST /api/commands/list
  698. * List all available commands from project and user directories
  699. *
  700. * Discovery layout:
  701. * - Built-in commands: hardcoded in this file (handled by builtInHandlers).
  702. * - Bundled skills: hardcoded stubs (BUNDLED_SKILL_STUBS) — actual handlers
  703. * live in the CLI binary; we only surface them so the UI menu shows them.
  704. * - On-disk commands: `.pilotdeck/commands/**\/*.md` (project + user).
  705. *
  706. * Dedup: when the same `/<name>` exists in multiple places, project wins over
  707. * user, and `commands/` wins over `skills/` (first-seen preference).
  708. * Bundled stubs only surface when no on-disk override exists.
  709. *
  710. * Pinning: PINNED_COMMAND_NAMES are reassigned `namespace: 'pinned'` so the
  711. * frontend menu pulls them into a curated top group, in fixed order.
  712. */
  713. router.post('/list', async (req, res) => {
  714. try {
  715. const { projectPath } = req.body;
  716. const homeDir = os.homedir();
  717. const customCommandSources = [];
  718. if (projectPath) {
  719. const projectCommandsDir = path.join(projectPath, '.pilotdeck', 'commands');
  720. const projectSkillsDir = path.join(projectPath, '.pilotdeck', 'skills');
  721. const [projectCommands, projectSkills] = await Promise.all([
  722. scanCommandsDirectory(projectCommandsDir, projectCommandsDir, 'project'),
  723. scanSkillsDirectory(projectSkillsDir, 'project'),
  724. ]);
  725. customCommandSources.push(...projectCommands, ...projectSkills);
  726. }
  727. const userCommandsDir = path.join(homeDir, '.pilotdeck', 'commands');
  728. const userSkillsDir = path.join(homeDir, '.pilotdeck', 'skills');
  729. const [userCommands, userSkills] = await Promise.all([
  730. scanCommandsDirectory(userCommandsDir, userCommandsDir, 'user'),
  731. scanSkillsDirectory(userSkillsDir, 'user'),
  732. ]);
  733. customCommandSources.push(...userCommands, ...userSkills);
  734. // Track every name we've committed so far to a single namespace. Built-in
  735. // names take precedence over disk customs and bundled stubs (their server-
  736. // side handlers in `builtInHandlers` are authoritative).
  737. const seenNames = new Set(builtInCommands.map((cmd) => cmd.name));
  738. const dedupedCustom = [];
  739. for (const cmd of customCommandSources) {
  740. if (seenNames.has(cmd.name)) continue;
  741. seenNames.add(cmd.name);
  742. dedupedCustom.push(cmd);
  743. }
  744. const builtInsWithBundled = [...builtInCommands];
  745. for (const stub of BUNDLED_SKILL_STUBS) {
  746. if (seenNames.has(stub.name)) continue;
  747. builtInsWithBundled.push({
  748. ...stub,
  749. namespace: 'builtin',
  750. });
  751. seenNames.add(stub.name);
  752. }
  753. dedupedCustom.sort((a, b) => a.name.localeCompare(b.name));
  754. const pinnedSet = new Set(PINNED_COMMAND_NAMES);
  755. const promote = (cmd) =>
  756. pinnedSet.has(cmd.name) ? { ...cmd, namespace: 'pinned' } : cmd;
  757. const builtIn = builtInsWithBundled.map(promote);
  758. const custom = dedupedCustom.map(promote);
  759. const indexByName = new Map();
  760. for (const cmd of [...builtIn, ...custom]) {
  761. if (!indexByName.has(cmd.name)) indexByName.set(cmd.name, cmd);
  762. }
  763. const pinnedOrdered = PINNED_COMMAND_NAMES
  764. .map((name) => indexByName.get(name))
  765. .filter(Boolean);
  766. res.json({
  767. builtIn,
  768. custom,
  769. pinned: pinnedOrdered,
  770. count: builtIn.length + custom.length,
  771. });
  772. } catch (error) {
  773. console.error('Error listing commands:', error);
  774. res.status(500).json({
  775. error: 'Failed to list commands',
  776. message: error.message,
  777. });
  778. }
  779. });
  780. /**
  781. * POST /api/commands/load
  782. * Load a specific command file and return its content and metadata
  783. */
  784. router.post('/load', async (req, res) => {
  785. try {
  786. const { commandPath } = req.body;
  787. if (!commandPath) {
  788. return res.status(400).json({
  789. error: 'Command path is required'
  790. });
  791. }
  792. // Security: Prevent path traversal. Allow paths under any
  793. const resolvedPath = path.resolve(commandPath);
  794. const inHome = resolvedPath.startsWith(path.resolve(os.homedir()));
  795. const inPilotdeckSubdir = /\.pilotdeck\/(commands|skills)\//.test(resolvedPath);
  796. if (!inHome && !inPilotdeckSubdir) {
  797. return res.status(403).json({
  798. error: 'Access denied',
  799. message: 'Command must be in a .pilotdeck/commands or .pilotdeck/skills directory'
  800. });
  801. }
  802. // Read and parse the command file
  803. const content = await fs.readFile(commandPath, 'utf8');
  804. const { data: metadata, content: commandContent } = parseFrontmatter(content);
  805. res.json({
  806. path: commandPath,
  807. metadata,
  808. content: commandContent
  809. });
  810. } catch (error) {
  811. if (error.code === 'ENOENT') {
  812. return res.status(404).json({
  813. error: 'Command not found',
  814. message: `Command file not found: ${req.body.commandPath}`
  815. });
  816. }
  817. console.error('Error loading command:', error);
  818. res.status(500).json({
  819. error: 'Failed to load command',
  820. message: error.message
  821. });
  822. }
  823. });
  824. /**
  825. * POST /api/commands/execute
  826. * Execute a command with argument replacement
  827. * This endpoint prepares the command content but doesn't execute bash commands yet
  828. * (that will be handled in the command parser utility)
  829. */
  830. router.post('/execute', async (req, res) => {
  831. try {
  832. const { commandName, commandPath, args = [], context = {} } = req.body;
  833. if (!commandName) {
  834. return res.status(400).json({
  835. error: 'Command name is required'
  836. });
  837. }
  838. // Handle built-in commands
  839. const handler = builtInHandlers[commandName];
  840. if (handler) {
  841. try {
  842. const result = await handler(args, context);
  843. return res.json({
  844. ...result,
  845. command: commandName
  846. });
  847. } catch (error) {
  848. console.error(`Error executing built-in command ${commandName}:`, error);
  849. return res.status(500).json({
  850. error: 'Command execution failed',
  851. message: error.message,
  852. command: commandName
  853. });
  854. }
  855. }
  856. // Bundled-skill stubs (e.g. /projects, /add-project) have no on-disk
  857. // file — the CLI's `registerBundledSkill` registry handles the
  858. // actual execution. Send the raw `/<name> <args>` text back as a
  859. // passthrough so the frontend submits it as normal user input; the proxy's
  860. // slash parser then routes to the bundled skill.
  861. const isBundledStub = BUNDLED_SKILL_STUBS.some(
  862. (stub) => stub.name === commandName,
  863. );
  864. if (isBundledStub) {
  865. const argsString = args.join(' ').trim();
  866. const passthroughContent = argsString
  867. ? `${commandName} ${argsString}`
  868. : commandName;
  869. return res.json({
  870. type: 'custom',
  871. command: commandName,
  872. content: passthroughContent,
  873. metadata: { type: 'bundled-skill', passthrough: true },
  874. hasFileIncludes: false,
  875. hasBashCommands: false,
  876. });
  877. }
  878. // server-side and submitted as raw user input — that would dump the whole
  879. // SKILL.md body into chat. Instead, passthrough the slash text so the
  880. // proxy's slash parser invokes SkillTool with the procedural body.
  881. if (commandPath && /\/\.pilotdeck\/skills\/[^/]+\/SKILL\.md$/i.test(commandPath)) {
  882. const argsString = args.join(' ').trim();
  883. const passthroughContent = argsString
  884. ? `${commandName} ${argsString}`
  885. : commandName;
  886. return res.json({
  887. type: 'custom',
  888. command: commandName,
  889. content: passthroughContent,
  890. metadata: { type: 'skill', passthrough: true },
  891. hasFileIncludes: false,
  892. hasBashCommands: false,
  893. });
  894. }
  895. if (!commandPath) {
  896. return res.status(400).json({
  897. error: 'Command path is required for custom commands'
  898. });
  899. }
  900. // Load command content
  901. // Security: validate commandPath is within allowed directories.
  902. {
  903. const resolvedPath = path.resolve(commandPath);
  904. const allowedBases = [
  905. path.resolve(path.join(os.homedir(), '.pilotdeck', 'commands')),
  906. path.resolve(path.join(os.homedir(), '.pilotdeck', 'skills')),
  907. ];
  908. if (context?.projectPath) {
  909. allowedBases.push(
  910. path.resolve(path.join(context.projectPath, '.pilotdeck', 'commands')),
  911. path.resolve(path.join(context.projectPath, '.pilotdeck', 'skills')),
  912. );
  913. }
  914. const isUnder = (base) => {
  915. const rel = path.relative(base, resolvedPath);
  916. return rel !== '' && !rel.startsWith('..') && !path.isAbsolute(rel);
  917. };
  918. if (!allowedBases.some(isUnder)) {
  919. return res.status(403).json({
  920. error: 'Access denied',
  921. message: 'Command must be in a .pilotdeck/commands or .pilotdeck/skills directory'
  922. });
  923. }
  924. }
  925. const content = await fs.readFile(commandPath, 'utf8');
  926. const { data: metadata, content: commandContent } = parseFrontmatter(content);
  927. // Basic argument replacement (will be enhanced in command parser utility)
  928. let processedContent = commandContent;
  929. // Replace $ARGUMENTS with all arguments joined
  930. const argsString = args.join(' ');
  931. processedContent = processedContent.replace(/\$ARGUMENTS/g, argsString);
  932. // Replace $1, $2, etc. with positional arguments
  933. args.forEach((arg, index) => {
  934. const placeholder = `$${index + 1}`;
  935. processedContent = processedContent.replace(new RegExp(`\\${placeholder}\\b`, 'g'), arg);
  936. });
  937. res.json({
  938. type: 'custom',
  939. command: commandName,
  940. content: processedContent,
  941. metadata,
  942. hasFileIncludes: processedContent.includes('@'),
  943. hasBashCommands: processedContent.includes('!')
  944. });
  945. } catch (error) {
  946. if (error.code === 'ENOENT') {
  947. return res.status(404).json({
  948. error: 'Command not found',
  949. message: `Command file not found: ${req.body.commandPath}`
  950. });
  951. }
  952. console.error('Error executing command:', error);
  953. res.status(500).json({
  954. error: 'Failed to execute command',
  955. message: error.message
  956. });
  957. }
  958. });
  959. export default router;