import { stat, writeFile, rm, mkdir } from "node:fs/promises"; import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; import * as log from "../../core/log.ts"; import { stringify as yamlStringify } from "yaml"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { SKILLS, SKILL_CONTENT, SCHEMAS, SCHEMA_MAPPINGS } from "../../guidance/skills.ts"; import { generateAgentsMdContent } from "../../guidance/core.ts"; /** * Format a YAML schema for inclusion in skill markdown files. */ function formatSchemaForMarkdown(schemaYaml: string, schemaName: string, filePattern: string): string { return `## ${schemaName} (\`${filePattern}\`) Must be a YAML file that adheres to the following schema: \`\`\`yaml ${schemaYaml.trim()} \`\`\``; } export interface InitOptions { useDefault?: boolean; useBackend?: boolean; repository?: string; workspace?: string; debug?: unknown; showDiffs?: boolean; token?: string; baseUrl?: string; configDir?: string; bindProfile?: boolean; } /** * Bootstrap a windmill project with a wmill.yaml file */ async function initAction(opts: InitOptions) { if (await stat("wmill.yaml").catch(() => null)) { log.error(colors.red("wmill.yaml already exists")); } else { // Import DEFAULT_SYNC_OPTIONS from conf.ts const { DEFAULT_SYNC_OPTIONS } = await import("../../core/conf.ts"); // Create initial config with defaults const initialConfig = { ...DEFAULT_SYNC_OPTIONS } as any; // Add branch structure const { isGitRepository, getCurrentGitBranch } = await import( "../../utils/git.ts" ); if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { initialConfig.gitBranches = { [currentBranch]: { overrides: {} }, }; } else { initialConfig.gitBranches = {}; } } else { initialConfig.gitBranches = {}; } initialConfig.nonDottedPaths = true; await writeFile("wmill.yaml", yamlStringify(initialConfig), "utf-8"); log.info(colors.green("wmill.yaml created with default settings")); // Create lock file await readLockfile(); // Offer to bind workspace profile to current branch if (isGitRepository()) { const activeWorkspace = await getActiveWorkspaceOrFallback( opts as GlobalOptions ); const currentBranch = getCurrentGitBranch(); if (activeWorkspace && currentBranch) { // Determine binding behavior based on flags const shouldBind = opts.bindProfile === true; const shouldPrompt = opts.bindProfile === undefined && !!process.stdin.isTTY && !opts.useDefault; const shouldSkip = opts.bindProfile != true && (opts.useDefault || !!!process.stdin.isTTY); if (!shouldSkip) { // Show workspace info if we're binding or prompting if (shouldBind || shouldPrompt) { log.info( colors.yellow(`\nCurrent Git branch: ${colors.bold(currentBranch)}`) ); log.info( colors.yellow( `Active workspace profile: ${colors.bold(activeWorkspace.name)}` ) ); log.info( colors.yellow( ` ${activeWorkspace.workspaceId} on ${activeWorkspace.remote}` ) ); } if ( shouldBind || (shouldPrompt && (await Confirm.prompt({ message: "Bind workspace profile to current Git branch?", default: true, }))) ) { // Update the config with workspace binding const currentConfig = await import("../../core/conf.ts").then((m) => m.readConfigFile() ); if (!currentConfig.gitBranches) { currentConfig.gitBranches = {}; } if (!currentConfig.gitBranches[currentBranch]) { currentConfig.gitBranches[currentBranch] = { overrides: {} }; } log.info( `binding branch ${currentBranch} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` ); currentConfig.gitBranches[currentBranch].baseUrl = activeWorkspace.remote; currentConfig.gitBranches[currentBranch].workspaceId = activeWorkspace.workspaceId; await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); log.info( colors.green( `✓ Bound branch '${currentBranch}' to workspace '${activeWorkspace.name}'` ) ); } } } } // Check for backend git-sync settings unless --use-default is specified if (!opts.useDefault) { try { const { requireLogin } = await import("../../core/auth.ts"); const { resolveWorkspace } = await import("../../core/context.ts"); // Check if user has workspace configured const { getActiveWorkspace } = await import( "../workspace/workspace.ts" ); const activeWorkspace = await getActiveWorkspace(opts as GlobalOptions); if (!activeWorkspace) { log.info("No workspace configured. Using default settings."); log.info( "You can configure a workspace later with 'wmill workspace add'" ); } else { await requireLogin(opts as GlobalOptions); const workspace = await resolveWorkspace(opts as GlobalOptions); const wmill = await import("../../../gen/services.gen.ts"); const settings = await wmill.getSettings({ workspace: workspace.workspaceId, }); if ( settings.git_sync?.repositories && settings.git_sync.repositories.length > 0 ) { let useBackendSettings = opts.useBackend; // If repository is specified, implicitly use backend settings if (opts.repository && !opts.useDefault) { useBackendSettings = true; } if (useBackendSettings === undefined) { // Interactive prompt const { Select } = await import("@cliffy/prompt/select"); const choice = await Select.prompt({ message: "Git-sync settings found on backend. What would you like to do?", options: [ { name: "Use backend git-sync settings", value: "backend", }, { name: "Use default settings", value: "default", }, { name: "Cancel", value: "cancel", }, ], }); if (choice === "cancel") { // Clean up the created files try { await rm("wmill.yaml"); await rm("wmill-lock.yaml"); } catch (e) { // Ignore cleanup errors } log.info("Init cancelled"); process.exit(0); } useBackendSettings = choice === "backend"; } if (useBackendSettings) { log.info("Applying git-sync settings from backend..."); // Import and run the pull git-sync settings logic const { pullGitSyncSettings } = await import( "../gitsync-settings/gitsync-settings.ts" ); await pullGitSyncSettings({ ...(opts as GlobalOptions), repository: opts.repository, jsonOutput: false, diff: false, replace: true, // Auto-replace when using backend settings during init }); log.info(colors.green("Git-sync settings applied from backend")); } } } } catch (error) { // If there's an error checking backend settings, just continue with defaults log.warn( `Could not check backend for git-sync settings: ${(error as Error).message}` ); log.info("Continuing with default settings"); } } } // Read nonDottedPaths from config to specialize generated skills let nonDottedPaths = true; // default for new inits try { const { readConfigFile } = await import("../../core/conf.ts"); const config = await readConfigFile(); nonDottedPaths = config.nonDottedPaths ?? true; } catch { // If config can't be read, use default } // Create guidance files (AGENTS.md, CLAUDE.md, and Claude skills) try { // Generate skills reference section for AGENTS.md const skills_base_dir = ".claude/skills"; const skillsReference = SKILLS.map( (s) => `- \`${skills_base_dir}/${s.name}/SKILL.md\` - ${s.description}` ).join("\n"); // Create AGENTS.md file with minimal instructions if (!(await stat("AGENTS.md").catch(() => null))) { await writeFile( "AGENTS.md", generateAgentsMdContent(skillsReference), "utf-8" ); log.info(colors.green("Created AGENTS.md")); } // Create CLAUDE.md file, referencing AGENTS.md if (!(await stat("CLAUDE.md").catch(() => null))) { await writeFile( "CLAUDE.md", `Instructions are in @AGENTS.md `, "utf-8" ); log.info(colors.green("Created CLAUDE.md")); } // Create .claude/skills/ directory and skill files try { await mkdir(".claude/skills", { recursive: true }); await Promise.all( SKILLS.map(async (skill) => { const skillDir = `.claude/skills/${skill.name}`; await mkdir(skillDir, { recursive: true }); let skillContent = SKILL_CONTENT[skill.name]; if (skillContent) { // Replace placeholders with actual suffixes based on nonDottedPaths if (nonDottedPaths) { skillContent = skillContent .replaceAll("{{FLOW_SUFFIX}}", "__flow") .replaceAll("{{APP_SUFFIX}}", "__app") .replaceAll("{{RAW_APP_SUFFIX}}", "__raw_app") .replaceAll("{{INLINE_SCRIPT_NAMING}}", "Inline script files should NOT include `.inline_script.` in their names (e.g. use `a.ts`, not `a.inline_script.ts`)."); } else { skillContent = skillContent .replaceAll("{{FLOW_SUFFIX}}", ".flow") .replaceAll("{{APP_SUFFIX}}", ".app") .replaceAll("{{RAW_APP_SUFFIX}}", ".raw_app") .replaceAll("{{INLINE_SCRIPT_NAMING}}", "Inline script files use the `.inline_script.` naming convention (e.g. `a.inline_script.ts`)."); } // Check if this skill has schemas that need to be appended const schemaMappings = SCHEMA_MAPPINGS[skill.name]; if (schemaMappings && schemaMappings.length > 0) { // Combine base content with schemas const schemaDocs = schemaMappings .map((mapping) => { const schemaYaml = SCHEMAS[mapping.schemaKey]; if (schemaYaml) { return formatSchemaForMarkdown(schemaYaml, mapping.name, mapping.filePattern); } return null; }) .filter((doc): doc is string => doc !== null); if (schemaDocs.length > 0) { skillContent = skillContent + "\n\n" + schemaDocs.join("\n\n"); } } await writeFile(`${skillDir}/SKILL.md`, skillContent, "utf-8"); } }) ); log.info(colors.green(`Created .claude/skills/ with ${SKILLS.length} skills`)); } catch (skillError) { if (skillError instanceof Error) { log.warn(`Could not create skills: ${skillError.message}`); } else { log.warn(`Could not create skills: ${skillError}`); } } } catch (error) { if (error instanceof Error) { log.warn(`Could not create guidance files: ${error.message}`); } else { log.warn(`Could not create guidance files: ${error}`); } } // Generate resource type namespace try { await generateRTNamespace(opts as GlobalOptions); } catch (error) { log.warn( `Could not pull resource types and generate TypeScript namespace: ${ error instanceof Error ? error.message : error }` ); } } const command = new Command() .description("Bootstrap a windmill project with a wmill.yaml file") .option("--use-default", "Use default settings without checking backend") .option("--use-backend", "Use backend git-sync settings if available") .option( "--repository ", "Specify repository path (e.g., u/user/repo) when using backend settings" ) .option( "--bind-profile", "Automatically bind active workspace profile to current Git branch" ) .option("--no-bind-profile", "Skip workspace profile binding prompt") .action(initAction as any); export default command;