#!/usr/bin/env node /** * Bootstrap ~/.pilotdeck/pilotdeck.yaml when it doesn't exist yet, so the * gateway can boot and the Web UI can run the onboarding flow that fills in * real provider details. On every startup, also sync repo-provided skills * into ~/.pilotdeck/skills without overwriting existing targets. * * Behaviour: * 1. Every run: discover repo skills and copy missing slugs into * $PILOT_HOME/skills, skipping existing targets. * 2. Every run: if pilotdeck.yaml exists but is missing known sections * (e.g. adapters), append the default snippet so new features are * discoverable without requiring users to recreate the config. * 3. If $PILOT_HOME/pilotdeck.yaml does not exist, write a minimal V2 yaml that: * - has a valid agent.model that resolves to a catalog provider/model, * so the engine's parseModelConfig won't crash on startup * - uses a sentinel apiKey ("PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE") * that hasUsablePilotDeckConfig() recognises as "not ready" so the * UI redirects to onboarding instead of pretending it's configured. * * Override the target via $PILOT_HOME (same env var the engine reads). * Skip the whole step via $PILOTDECK_SKIP_BOOTSTRAP=1. */ import { appendFileSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { basename, dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; const SENTINEL_API_KEY = 'PLACEHOLDER_RUN_ONBOARDING_TO_REPLACE'; const __filename = fileURLToPath(import.meta.url); const REPO_ROOT = resolve(dirname(__filename), '..'); const BOOTSTRAP_YAML = `# Auto-generated by scripts/bootstrap-pilotdeck-config.mjs. # Open the Web UI to finish onboarding — choose a provider, paste an API key, # and pick a model. The gateway boots with this placeholder config so the # server can start, but apiKey="${SENTINEL_API_KEY}" tells the UI # to redirect to the onboarding screen. schemaVersion: 1 agent: model: _placeholder/_placeholder model: providers: _placeholder: protocol: openai url: https://placeholder.invalid apiKey: ${SENTINEL_API_KEY} models: _placeholder: capabilities: maxOutputTokens: 16384 adapters: feishu: enabled: false appId: "" appSecret: "" # connectionMode: stream # domainName: feishu router: enabled: true scenarios: default: _placeholder/_placeholder fallback: default: - _placeholder/_placeholder zeroUsageRetry: enabled: true maxAttempts: 2 tokenSaver: enabled: true judge: _placeholder/_placeholder defaultTier: medium judgeTimeoutMs: 15000 tiers: simple: model: _placeholder/_placeholder description: "Simple greetings, confirmations, single-step Q&A, trivial file writes, remembering rules" medium: model: _placeholder/_placeholder description: "Single tool call, short text generation, 1-2 file read/write, code generation" complex: model: _placeholder/_placeholder description: "Needs sub-agent orchestration: parallel workstreams, delegation to specialized agents" reasoning: model: _placeholder/_placeholder description: "Deep single-agent work: multi-file operations, data analysis, multi-step workflows, web research, structured reports from many sources" rules: - "complex is ONLY for tasks that need sub-agent orchestration or parallel delegation — do NOT use it for single-agent multi-step work" - "Multi-file operations, data analysis, and multi-step workflows without orchestration should be reasoning" - "Simple file creation (1-2 files) or single code generation is medium" - "Trivial greetings, confirmations, remembering rules, or reading one file and answering a short question is simple" autoOrchestrate: enabled: true triggerTiers: - complex slimSystemPrompt: true allowedTools: - agent - read_file - grep - glob - read_skill subagentMaxTokens: 48000 stats: enabled: true cron: enabled: true timezone: Asia/Shanghai maxConcurrentRuns: 2 `; function resolvePilotHome() { if (process.env.PILOT_HOME) return process.env.PILOT_HOME; return join(homedir(), '.pilotdeck'); } function discoverRepoSkillDirs(skillsRoot) { if (!existsSync(skillsRoot)) { return []; } const discovered = []; const pending = [skillsRoot]; while (pending.length > 0) { const current = pending.pop(); const entries = readdirSync(current, { withFileTypes: true }); if (entries.some((entry) => entry.isFile() && /^skill\.md$/i.test(entry.name))) { discovered.push(current); continue; } for (const entry of entries) { if (entry.isDirectory()) { pending.push(join(current, entry.name)); } } } discovered.sort((left, right) => left.localeCompare(right)); return discovered; } function syncRepoSkillsToPilotHome(pilotHome) { const repoSkillsRoot = join(REPO_ROOT, 'skills'); const skillDirs = discoverRepoSkillDirs(repoSkillsRoot); if (skillDirs.length === 0) { return { created: 0, skippedExisting: 0, skippedDuplicateSlug: 0 }; } const targetRoot = join(pilotHome, 'skills'); mkdirSync(targetRoot, { recursive: true }); let created = 0; let skippedExisting = 0; let skippedDuplicateSlug = 0; const seenSlugs = new Map(); for (const sourceDir of skillDirs) { const slug = basename(sourceDir); const previous = seenSlugs.get(slug); if (previous) { skippedDuplicateSlug += 1; console.warn( `[pilotdeck] Skipping repo skill '${slug}' at ${sourceDir}; slug already claimed by ${previous}.`, ); continue; } seenSlugs.set(slug, sourceDir); const targetPath = join(targetRoot, slug); if (existsSync(targetPath)) { skippedExisting += 1; continue; } try { cpSync(sourceDir, targetPath, { recursive: true }); created += 1; } catch (error) { console.warn( `[pilotdeck] Could not import repo skill '${slug}' into ${targetPath}: ${ error instanceof Error ? error.message : String(error) }`, ); } } return { created, skippedExisting, skippedDuplicateSlug }; } const DEFAULT_ADAPTERS_SNIPPET = ` adapters: feishu: enabled: false appId: "" appSecret: "" # connectionMode: stream # domainName: feishu `; const DEFAULT_CRON_SNIPPET = ` cron: enabled: true timezone: Asia/Shanghai maxConcurrentRuns: 2 `; const PATCH_SECTIONS = [ { key: 'adapters', snippet: DEFAULT_ADAPTERS_SNIPPET }, { key: 'cron', snippet: DEFAULT_CRON_SNIPPET }, ]; function patchMissingSections(configPath) { let content; try { content = readFileSync(configPath, 'utf8'); } catch { return; } let patched = false; for (const { key, snippet } of PATCH_SECTIONS) { const pattern = new RegExp(`^${key}\\s*:`, 'm'); if (!pattern.test(content)) { try { appendFileSync(configPath, snippet, 'utf8'); patched = true; console.log(`[pilotdeck] Appended missing "${key}" section to ${configPath}.`); } catch (error) { console.warn( `[pilotdeck] Could not append "${key}" to ${configPath}: ${ error instanceof Error ? error.message : String(error) }`, ); } } } return patched; } function main() { if (process.env.PILOTDECK_SKIP_BOOTSTRAP === '1') { return; } const pilotHome = resolvePilotHome(); const configPath = join(pilotHome, 'pilotdeck.yaml'); const skillSync = syncRepoSkillsToPilotHome(pilotHome); if (skillSync.created > 0 || skillSync.skippedExisting > 0 || skillSync.skippedDuplicateSlug > 0) { console.log( `[pilotdeck] Synced repo skills into ${join(pilotHome, 'skills')}: ` + `${skillSync.created} copied, ${skillSync.skippedExisting} skipped existing, ` + `${skillSync.skippedDuplicateSlug} skipped duplicate slug.`, ); } if (existsSync(configPath)) { patchMissingSections(configPath); return; } try { mkdirSync(dirname(configPath), { recursive: true }); writeFileSync(configPath, BOOTSTRAP_YAML, 'utf8'); console.log( `[pilotdeck] No config at ${configPath}; wrote a placeholder so the gateway can boot.`, ); console.log('[pilotdeck] Open the Web UI to finish onboarding (provider + API key).'); } catch (error) { console.warn( `[pilotdeck] Could not bootstrap ${configPath}: ${error instanceof Error ? error.message : String(error)}`, ); console.warn('[pilotdeck] You may need to create it manually before running npm run dev.'); } } main();