bootstrap-pilotdeck-config.mjs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276
  1. #!/usr/bin/env node
  2. /**
  3. * Bootstrap ~/.pilotdeck/pilotdeck.yaml when it doesn't exist yet, so the
  4. * gateway can boot and the Web UI can run the onboarding flow that fills in
  5. * real provider details. On every startup, also sync repo-provided skills
  6. * into ~/.pilotdeck/skills without overwriting existing targets.
  7. *
  8. * Behaviour:
  9. * 1. Every run: discover repo skills and copy missing slugs into
  10. * $PILOT_HOME/skills, skipping existing targets.
  11. * 2. Every run: if pilotdeck.yaml exists but is missing known sections
  12. * (e.g. adapters), append the default snippet so new features are
  13. * discoverable without requiring users to recreate the config.
  14. * 3. If $PILOT_HOME/pilotdeck.yaml does not exist, write a minimal V2 yaml that:
  15. * - has a valid agent.model that resolves to a catalog provider/model,
  16. * so the engine's parseModelConfig won't crash on startup
  17. * - uses a sentinel apiKey ("PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE")
  18. * that hasUsablePilotDeckConfig() recognises as "not ready" so the
  19. * UI redirects to onboarding instead of pretending it's configured.
  20. *
  21. * Override the target via $PILOT_HOME (same env var the engine reads).
  22. * Skip the whole step via $PILOTDECK_SKIP_BOOTSTRAP=1.
  23. */
  24. import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
  25. import { homedir } from 'node:os';
  26. import { basename, dirname, join, resolve } from 'node:path';
  27. import { fileURLToPath } from 'node:url';
  28. const SENTINEL_API_KEY = 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE';
  29. const __filename = fileURLToPath(import.meta.url);
  30. const REPO_ROOT = resolve(dirname(__filename), '..');
  31. const BOOTSTRAP_YAML = `# Auto-generated by scripts/bootstrap-pilotdeck-config.mjs.
  32. # Open the Web UI to finish onboarding — choose a provider, paste an API key,
  33. # and pick a model. The gateway boots with this placeholder config so the
  34. # server can start, but apiKey="${SENTINEL_API_KEY}" tells the UI
  35. # to redirect to the onboarding screen.
  36. schemaVersion: 1
  37. agent:
  38. model: _placeholder/_placeholder
  39. model:
  40. providers:
  41. _placeholder:
  42. protocol: openai
  43. url: https://placeholder.invalid
  44. apiKey: ${SENTINEL_API_KEY}
  45. models:
  46. _placeholder:
  47. capabilities:
  48. maxOutputTokens: 16384
  49. adapters:
  50. feishu:
  51. enabled: false
  52. appId: ""
  53. appSecret: ""
  54. # connectionMode: stream
  55. # domainName: feishu
  56. router:
  57. enabled: true
  58. scenarios:
  59. default: _placeholder/_placeholder
  60. fallback:
  61. default:
  62. - _placeholder/_placeholder
  63. zeroUsageRetry:
  64. enabled: true
  65. maxAttempts: 2
  66. tokenSaver:
  67. enabled: true
  68. judge: _placeholder/_placeholder
  69. defaultTier: medium
  70. judgeTimeoutMs: 15000
  71. tiers:
  72. simple:
  73. model: _placeholder/_placeholder
  74. description: "Simple greetings, confirmations, single-step Q&A, trivial file writes, remembering rules"
  75. medium:
  76. model: _placeholder/_placeholder
  77. description: "Single tool call, short text generation, 1-2 file read/write, code generation"
  78. complex:
  79. model: _placeholder/_placeholder
  80. description: "Needs sub-agent orchestration: parallel workstreams, delegation to specialized agents"
  81. reasoning:
  82. model: _placeholder/_placeholder
  83. description: "Deep single-agent work: multi-file operations, data analysis, multi-step workflows, web research, structured reports from many sources"
  84. rules:
  85. - "complex is ONLY for tasks that need sub-agent orchestration or parallel delegation — do NOT use it for single-agent multi-step work"
  86. - "Multi-file operations, data analysis, and multi-step workflows without orchestration should be reasoning"
  87. - "Simple file creation (1-2 files) or single code generation is medium"
  88. - "Trivial greetings, confirmations, remembering rules, or reading one file and answering a short question is simple"
  89. autoOrchestrate:
  90. enabled: true
  91. triggerTiers:
  92. - complex
  93. slimSystemPrompt: true
  94. allowedTools:
  95. - agent
  96. - read_file
  97. - grep
  98. - glob
  99. - read_skill
  100. subagentMaxTokens: 48000
  101. stats:
  102. enabled: true
  103. cron:
  104. enabled: true
  105. timezone: Asia/Shanghai
  106. maxConcurrentRuns: 2
  107. `;
  108. function resolvePilotHome() {
  109. if (process.env.PILOT_HOME) return process.env.PILOT_HOME;
  110. return join(homedir(), '.pilotdeck');
  111. }
  112. function discoverRepoSkillDirs(skillsRoot) {
  113. if (!existsSync(skillsRoot)) {
  114. return [];
  115. }
  116. const discovered = [];
  117. const pending = [skillsRoot];
  118. while (pending.length > 0) {
  119. const current = pending.pop();
  120. const entries = readdirSync(current, { withFileTypes: true });
  121. if (entries.some((entry) => entry.isFile() && /^skill\.md$/i.test(entry.name))) {
  122. discovered.push(current);
  123. continue;
  124. }
  125. for (const entry of entries) {
  126. if (entry.isDirectory()) {
  127. pending.push(join(current, entry.name));
  128. }
  129. }
  130. }
  131. discovered.sort((left, right) => left.localeCompare(right));
  132. return discovered;
  133. }
  134. function syncRepoSkillsToPilotHome(pilotHome) {
  135. const repoSkillsRoot = join(REPO_ROOT, 'skills');
  136. const skillDirs = discoverRepoSkillDirs(repoSkillsRoot);
  137. if (skillDirs.length === 0) {
  138. return { created: 0, skippedExisting: 0, skippedDuplicateSlug: 0 };
  139. }
  140. const targetRoot = join(pilotHome, 'skills');
  141. mkdirSync(targetRoot, { recursive: true });
  142. let created = 0;
  143. let skippedExisting = 0;
  144. let skippedDuplicateSlug = 0;
  145. const seenSlugs = new Map();
  146. for (const sourceDir of skillDirs) {
  147. const slug = basename(sourceDir);
  148. const previous = seenSlugs.get(slug);
  149. if (previous) {
  150. skippedDuplicateSlug += 1;
  151. console.warn(
  152. `[pilotdeck] Skipping repo skill '${slug}' at ${sourceDir}; slug already claimed by ${previous}.`,
  153. );
  154. continue;
  155. }
  156. seenSlugs.set(slug, sourceDir);
  157. const targetPath = join(targetRoot, slug);
  158. if (existsSync(targetPath)) {
  159. skippedExisting += 1;
  160. continue;
  161. }
  162. try {
  163. cpSync(sourceDir, targetPath, { recursive: true });
  164. created += 1;
  165. } catch (error) {
  166. console.warn(
  167. `[pilotdeck] Could not import repo skill '${slug}' into ${targetPath}: ${
  168. error instanceof Error ? error.message : String(error)
  169. }`,
  170. );
  171. }
  172. }
  173. return { created, skippedExisting, skippedDuplicateSlug };
  174. }
  175. const DEFAULT_ADAPTERS_SNIPPET = `
  176. adapters:
  177. feishu:
  178. enabled: false
  179. appId: ""
  180. appSecret: ""
  181. # connectionMode: stream
  182. # domainName: feishu
  183. `;
  184. const DEFAULT_CRON_SNIPPET = `
  185. cron:
  186. enabled: true
  187. timezone: Asia/Shanghai
  188. maxConcurrentRuns: 2
  189. `;
  190. const PATCH_SECTIONS = [
  191. { key: 'adapters', snippet: DEFAULT_ADAPTERS_SNIPPET },
  192. { key: 'cron', snippet: DEFAULT_CRON_SNIPPET },
  193. ];
  194. function patchMissingSections(configPath) {
  195. let content;
  196. try {
  197. content = readFileSync(configPath, 'utf8');
  198. } catch {
  199. return;
  200. }
  201. let patched = false;
  202. for (const { key, snippet } of PATCH_SECTIONS) {
  203. const pattern = new RegExp(`^${key}\\s*:`, 'm');
  204. if (!pattern.test(content)) {
  205. try {
  206. appendFileSync(configPath, snippet, 'utf8');
  207. patched = true;
  208. console.log(`[pilotdeck] Appended missing "${key}" section to ${configPath}.`);
  209. } catch (error) {
  210. console.warn(
  211. `[pilotdeck] Could not append "${key}" to ${configPath}: ${
  212. error instanceof Error ? error.message : String(error)
  213. }`,
  214. );
  215. }
  216. }
  217. }
  218. return patched;
  219. }
  220. function main() {
  221. if (process.env.PILOTDECK_SKIP_BOOTSTRAP === '1') {
  222. return;
  223. }
  224. const pilotHome = resolvePilotHome();
  225. const configPath = join(pilotHome, 'pilotdeck.yaml');
  226. const skillSync = syncRepoSkillsToPilotHome(pilotHome);
  227. if (skillSync.created > 0 || skillSync.skippedExisting > 0 || skillSync.skippedDuplicateSlug > 0) {
  228. console.log(
  229. `[pilotdeck] Synced repo skills into ${join(pilotHome, 'skills')}: ` +
  230. `${skillSync.created} copied, ${skillSync.skippedExisting} skipped existing, ` +
  231. `${skillSync.skippedDuplicateSlug} skipped duplicate slug.`,
  232. );
  233. }
  234. if (existsSync(configPath)) {
  235. patchMissingSections(configPath);
  236. return;
  237. }
  238. try {
  239. mkdirSync(dirname(configPath), { recursive: true });
  240. writeFileSync(configPath, BOOTSTRAP_YAML, 'utf8');
  241. console.log(
  242. `[pilotdeck] No config at ${configPath}; wrote a placeholder so the gateway can boot.`,
  243. );
  244. console.log('[pilotdeck] Open the Web UI to finish onboarding (provider + API key).');
  245. } catch (error) {
  246. console.warn(
  247. `[pilotdeck] Could not bootstrap ${configPath}: ${error instanceof Error ? error.message : String(error)}`,
  248. );
  249. console.warn('[pilotdeck] You may need to create it manually before running npm run dev.');
  250. }
  251. }
  252. main();