import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from "fs"; import { join, resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { homedir } from "os"; import { createInterface } from "readline"; import { formatSuccess, formatWarning } from "../format"; import { loadPlugin } from "../plugins"; /** Read the current chant package version from our own package.json. */ export function getChantVersion(): string { try { const pkgDir = dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))); const pkg = JSON.parse(readFileSync(join(pkgDir, "package.json"), "utf-8")); return pkg.version ?? "0.0.13"; } catch { return "0.0.13"; } } /** * Init command options */ export interface InitOptions { /** Target directory (defaults to cwd) */ path?: string; /** Lexicon to use */ lexicon: string; /** Template name (e.g. "node-pipeline", "docker-build") */ template?: string; /** Force init even in non-empty directory */ force?: boolean; /** Skip MCP config generation */ skipMcp?: boolean; /** Skip interactive install prompt */ skipInstall?: boolean; /** * If set, install only the named skill (e.g. "chant-gitlab-migrate") * rather than every skill the plugin exports. Useful for incremental * skill installation without re-scaffolding the project. */ skill?: string; } /** * Init command result */ export interface InitResult { /** Whether init succeeded */ success: boolean; /** Created files */ createdFiles: string[]; /** Warning messages */ warnings: string[]; /** Error message if failed */ error?: string; } /** * Detect whether the user's project uses bun or npm. * Checks for lock files. */ function detectPackageManager(dir?: string): "bun" | "npm" { if (dir && (existsSync(join(dir, "bun.lockb")) || existsSync(join(dir, "bun.lock")))) return "bun"; return "npm"; } /** * Detect the IDE environment for MCP config */ function detectIdeEnvironment(): "claude-code" | "cursor" | "generic" { // Check for Claude Code if (existsSync(join(homedir(), ".claude"))) { return "claude-code"; } // Check for Cursor if (existsSync(join(homedir(), ".cursor"))) { return "cursor"; } return "generic"; } /** * Get MCP config directory based on IDE */ function getMcpConfigDir(ide: "claude-code" | "cursor" | "generic"): string { switch (ide) { case "claude-code": return join(homedir(), ".claude"); case "cursor": return join(homedir(), ".cursor"); default: return join(homedir(), ".config", "mcp"); } } /** * Generate package.json content */ function generatePackageJson(lexicon: string, extraScripts?: Record): string { const ver = getChantVersion(); const dependencies: Record = { "@intentius/chant": `^${ver}`, [`@intentius/chant-lexicon-${lexicon}`]: `^${ver}`, }; const pkg = { name: "chant-project", version: ver, type: "module" as const, scripts: { build: `chant build src --lexicon ${lexicon}`, lint: "chant lint src", dev: `chant build src --lexicon ${lexicon} --watch`, ...extraScripts, }, dependencies, devDependencies: { typescript: "^5.0.0", }, }; return JSON.stringify(pkg, null, 2); } /** * Generate tsconfig.json content with path mappings for .chant/ types */ function generateTsConfig(lexicon: string): string { const config = { compilerOptions: { target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext", strict: true, esModuleInterop: true, skipLibCheck: true, declaration: true, outDir: "./dist", rootDir: "./src", }, include: ["src"], exclude: ["node_modules", "dist"], }; return JSON.stringify(config, null, 2); } /** * Generate chant.config.ts content */ function generateChantConfig(lexicon: string): string { return `import type { ChantConfig } from "@intentius/chant"; export default { lexicons: ["${lexicon}"], } satisfies ChantConfig; `; } /** * Generate .gitignore content */ function generateGitignore(): string { return `dist/ node_modules/ .chant/types/ .chant/meta/ .chant/rules/ skills/ `; } /** * Generate embedded core type definitions for .chant/types/core/ */ function generateCoreTypeDefs(): string { return `// @intentius/chant — core type definitions // Run "chant update" to sync the latest types /** Wraps a value that may be a literal or an intrinsic expression */ export type Value = T | Intrinsic; /** Marker interface for intrinsic functions (Ref, Sub, etc.) */ export interface Intrinsic { toJSON(): unknown; } /** Serializer interface for chant specifications */ export interface Serializer { name: string; rulePrefix: string; serialize(entities: Map, outputs?: LexiconOutput[]): string; serializeCrossRef?(output: LexiconOutput): unknown; } /** Base interface for all declarable entities */ export interface Declarable { readonly lexicon: string; readonly entityType: string; readonly kind?: "resource" | "property"; } /** Cross-lexicon output reference */ export interface LexiconOutput { readonly outputName: string; readonly sourceEntity: string; readonly sourceAttribute: string; readonly lexicon: string; } /** Top-level project configuration */ export interface ChantConfig { lexicons?: string[]; lint?: { rules?: Record]>; extends?: string[]; plugins?: string[]; }; } `; } /** * Generate MCP config */ function generateMcpConfig(pm: "bun" | "npm"): string { const config = { mcpServers: { chant: { command: pm === "bun" ? "bunx" : "npx", args: ["chant", "serve", "mcp"], }, }, }; return JSON.stringify(config, null, 2); } /** * Prompt user for install */ async function promptInstall(): Promise { const rl = createInterface({ input: process.stdin, output: process.stdout, }); return new Promise((resolve) => { rl.question("Install dependencies? (Y/n) ", (answer) => { rl.close(); const trimmed = answer.trim().toLowerCase(); resolve(trimmed === "" || trimmed === "y" || trimmed === "yes"); }); }); } /** * Write a file if it doesn't exist, tracking created files and warnings */ function writeIfNotExists( filePath: string, content: string, relativePath: string, createdFiles: string[], warnings: string[], ): void { if (!existsSync(filePath)) { writeFileSync(filePath, content); createdFiles.push(relativePath); } else { warnings.push(`${relativePath} already exists, skipping`); } } /** * Execute the init command */ export async function initCommand(options: InitOptions): Promise { const targetDir = resolve(options.path ?? "."); const createdFiles: string[] = []; const warnings: string[] = []; // Check if directory is non-empty if (existsSync(targetDir)) { const contents = readdirSync(targetDir); const nonHiddenFiles = contents.filter((f) => !f.startsWith(".")); if (nonHiddenFiles.length > 0 && !options.force) { return { success: false, createdFiles: [], warnings: [], error: `Directory is not empty. Use --force to initialize anyway.`, }; } if (nonHiddenFiles.length > 0) { warnings.push("Initializing in non-empty directory"); } } // Create target directory if it doesn't exist if (!existsSync(targetDir)) { mkdirSync(targetDir, { recursive: true }); } // Load plugin early to get template set (used for scripts + source files) let templateSet: import("../../lexicon").InitTemplateSet | undefined; try { const plugin = await loadPlugin(options.lexicon); if (plugin.initTemplates) { templateSet = plugin.initTemplates(options.template); } } catch { // Plugin not yet installed — no source files to scaffold } // Create src directory const srcDir = join(targetDir, "src"); if (!existsSync(srcDir)) { mkdirSync(srcDir, { recursive: true }); } // Generate package.json writeIfNotExists( join(targetDir, "package.json"), generatePackageJson(options.lexicon, templateSet?.scripts), "package.json", createdFiles, warnings, ); // Generate tsconfig.json writeIfNotExists( join(targetDir, "tsconfig.json"), generateTsConfig(options.lexicon), "tsconfig.json", createdFiles, warnings, ); // Generate chant.config.ts writeIfNotExists( join(targetDir, "chant.config.ts"), generateChantConfig(options.lexicon), "chant.config.ts", createdFiles, warnings, ); // Generate .gitignore writeIfNotExists( join(targetDir, ".gitignore"), generateGitignore(), ".gitignore", createdFiles, warnings, ); // Write source files from plugin template set if (templateSet) { for (const [filename, content] of Object.entries(templateSet.src)) { writeIfNotExists( join(srcDir, filename), content, `src/${filename}`, createdFiles, warnings, ); } // Write root scaffold files (e.g. index.js, test.js, Dockerfile) if (templateSet.root) { for (const [filename, content] of Object.entries(templateSet.root)) { writeIfNotExists( join(targetDir, filename), content, filename, createdFiles, warnings, ); } } } // Scaffold .chant/types/core/ with embedded type definitions const coreTypesDir = join(targetDir, ".chant", "types", "core"); mkdirSync(coreTypesDir, { recursive: true }); writeIfNotExists( join(coreTypesDir, "package.json"), JSON.stringify({ name: "@intentius/chant", version: "0.0.0", types: "./index.d.ts" }, null, 2), ".chant/types/core/package.json", createdFiles, warnings, ); writeIfNotExists( join(coreTypesDir, "index.d.ts"), generateCoreTypeDefs(), ".chant/types/core/index.d.ts", createdFiles, warnings, ); // Scaffold .chant/types/lexicon-{lexicon}/ stub const lexiconTypesDir = join(targetDir, ".chant", "types", `lexicon-${options.lexicon}`); mkdirSync(lexiconTypesDir, { recursive: true }); writeIfNotExists( join(lexiconTypesDir, "package.json"), JSON.stringify( { name: `@intentius/chant-lexicon-${options.lexicon}`, version: "0.0.0", types: "./index.d.ts" }, null, 2, ), `.chant/types/lexicon-${options.lexicon}/package.json`, createdFiles, warnings, ); writeIfNotExists( join(lexiconTypesDir, "index.d.ts"), `// Lexicon type stubs — run "chant update" to sync full types\nexport {};\n`, `.chant/types/lexicon-${options.lexicon}/index.d.ts`, createdFiles, warnings, ); // Generate MCP config if (!options.skipMcp) { const ide = detectIdeEnvironment(); const mcpDir = getMcpConfigDir(ide); if (!existsSync(mcpDir)) { mkdirSync(mcpDir, { recursive: true }); } writeIfNotExists( join(mcpDir, "mcp.json"), generateMcpConfig(detectPackageManager(targetDir)), `~/.${ide === "generic" ? "config/mcp" : ide}/mcp.json`, createdFiles, warnings, ); } // Install skills from the lexicon's plugin. With --skill, install only // the matching skill; without, install all. try { const plugin = await loadPlugin(options.lexicon); if (plugin.skills) { const all = plugin.skills(); const skills = options.skill ? all.filter((s) => s.name === options.skill) : all; if (options.skill && skills.length === 0) { warnings.push(`No skill named "${options.skill}" in lexicon ${options.lexicon}; nothing installed`); } for (const skill of skills) { const skillDir = join(targetDir, "skills", skill.name); mkdirSync(skillDir, { recursive: true }); writeFileSync(join(skillDir, "SKILL.md"), skill.content); createdFiles.push(`skills/${skill.name}/SKILL.md`); } } } catch { // Skills are optional — don't fail init if plugin isn't installed yet } return { success: true, createdFiles, warnings, }; } /** * Print init result and prompt for install */ export async function printInitResult( result: InitResult, options?: { skipInstall?: boolean; cwd?: string }, ): Promise { if (!result.success) { console.error(result.error); return; } for (const warning of result.warnings) { console.error(formatWarning({ message: warning })); } if (result.createdFiles.length > 0) { console.log(formatSuccess("Created:")); for (const file of result.createdFiles) { console.log(` ${file}`); } } console.log(""); const pm = detectPackageManager(options?.cwd); // Interactive install prompt if (!options?.skipInstall) { const shouldInstall = await promptInstall(); if (shouldInstall) { const { execSync } = await import("child_process"); const cwd = options?.cwd ?? "."; console.log("Installing dependencies..."); try { execSync(`${pm} install`, { cwd, stdio: "inherit" }); } catch { console.error(formatWarning({ message: `Install failed. Run '${pm} install' manually.` })); } } } console.log(""); console.log("Next steps:"); console.log(" 1. Edit src/config.ts"); console.log(" 2. Add resources in src/"); console.log(` 3. ${pm} run build`); }