/**
* /init command — scan project and generate .whale/CLAUDE.md
*
* Two exports:
* - runInit() — standalone CLI subcommand (console.log output)
* - runInitInline() — returns string for in-chat /init
*/
import { existsSync, readFileSync, mkdirSync, writeFileSync, statSync } from "fs";
import { join, basename } from "path";
// ============================================================================
// PROJECT DETECTION
// ============================================================================
interface ProjectInfo {
name: string;
description: string;
language: string;
framework: string;
buildCmd: string;
testCmd: string;
srcDir: string;
testDir: string;
conventions: string[];
gitRemote: string;
}
function detectProject(cwd: string): ProjectInfo {
const info: ProjectInfo = {
name: basename(cwd),
description: "",
language: "",
framework: "",
buildCmd: "",
testCmd: "",
srcDir: "",
testDir: "",
conventions: [],
gitRemote: "",
};
// ── package.json (Node/JS/TS) ──
const pkgPath = join(cwd, "package.json");
if (existsSync(pkgPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
info.name = pkg.name || info.name;
info.description = pkg.description || "";
// Detect language
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
if (existsSync(join(cwd, "tsconfig.json")) || deps["typescript"]) {
info.language = "TypeScript";
} else {
info.language = "JavaScript";
}
// Detect framework
if (deps["next"]) info.framework = "Next.js";
else if (deps["react"] && deps["ink"]) info.framework = "React + Ink (CLI)";
else if (deps["react"]) info.framework = "React";
else if (deps["vue"]) info.framework = "Vue";
else if (deps["svelte"]) info.framework = "Svelte";
else if (deps["express"]) info.framework = "Express";
else if (deps["fastify"]) info.framework = "Fastify";
else if (deps["hono"]) info.framework = "Hono";
else if (deps["@angular/core"]) info.framework = "Angular";
// Build & test commands
if (pkg.scripts?.build) info.buildCmd = `npm run build`;
if (pkg.scripts?.test) info.testCmd = `npm test`;
if (pkg.scripts?.dev) info.buildCmd = info.buildCmd || `npm run dev`;
} catch { /* corrupt package.json */ }
}
// ── pyproject.toml / requirements.txt (Python) ──
if (existsSync(join(cwd, "pyproject.toml"))) {
info.language = info.language || "Python";
try {
const toml = readFileSync(join(cwd, "pyproject.toml"), "utf-8");
const nameMatch = toml.match(/name\s*=\s*"([^"]+)"/);
if (nameMatch) info.name = nameMatch[1];
if (toml.includes("django")) info.framework = "Django";
else if (toml.includes("fastapi")) info.framework = "FastAPI";
else if (toml.includes("flask")) info.framework = "Flask";
} catch { /* ignore */ }
if (!info.buildCmd) info.buildCmd = "python -m build";
if (!info.testCmd) info.testCmd = "pytest";
} else if (existsSync(join(cwd, "requirements.txt"))) {
info.language = info.language || "Python";
if (!info.testCmd) info.testCmd = "pytest";
}
// ── Cargo.toml (Rust) ──
if (existsSync(join(cwd, "Cargo.toml"))) {
info.language = info.language || "Rust";
try {
const cargo = readFileSync(join(cwd, "Cargo.toml"), "utf-8");
const nameMatch = cargo.match(/name\s*=\s*"([^"]+)"/);
if (nameMatch) info.name = nameMatch[1];
} catch { /* ignore */ }
info.buildCmd = info.buildCmd || "cargo build";
info.testCmd = info.testCmd || "cargo test";
}
// ── go.mod (Go) ──
if (existsSync(join(cwd, "go.mod"))) {
info.language = info.language || "Go";
try {
const gomod = readFileSync(join(cwd, "go.mod"), "utf-8");
const moduleMatch = gomod.match(/module\s+(\S+)/);
if (moduleMatch) info.name = moduleMatch[1].split("/").pop() || info.name;
} catch { /* ignore */ }
info.buildCmd = info.buildCmd || "go build ./...";
info.testCmd = info.testCmd || "go test ./...";
}
// ── README.md — first paragraph for description ──
if (!info.description) {
const readmePath = join(cwd, "README.md");
if (existsSync(readmePath)) {
try {
const readme = readFileSync(readmePath, "utf-8");
const lines = readme.split("\n");
// Skip title lines (# heading), blank lines, badges
let para = "";
for (const line of lines) {
if (line.startsWith("#") || line.startsWith("![") || line.startsWith("[![") || !line.trim()) {
if (para) break; // End of first paragraph
continue;
}
para += (para ? " " : "") + line.trim();
}
if (para && para.length < 200) info.description = para;
} catch { /* ignore */ }
}
}
// ── Git remote ──
const gitConfigPath = join(cwd, ".git", "config");
if (existsSync(gitConfigPath)) {
try {
const gitConfig = readFileSync(gitConfigPath, "utf-8");
const urlMatch = gitConfig.match(/url\s*=\s*(.+)/);
if (urlMatch) info.gitRemote = urlMatch[1].trim();
} catch { /* ignore */ }
}
// ── Directory structure ──
const dirExists = (name: string) => {
try { return statSync(join(cwd, name)).isDirectory(); } catch { return false; }
};
if (dirExists("src")) info.srcDir = "src/";
else if (dirExists("lib")) info.srcDir = "lib/";
else if (dirExists("app")) info.srcDir = "app/";
if (dirExists("tests")) info.testDir = "tests/";
else if (dirExists("test")) info.testDir = "test/";
else if (dirExists("__tests__")) info.testDir = "__tests__/";
else if (dirExists("spec")) info.testDir = "spec/";
// ── Conventions ──
if (existsSync(join(cwd, ".eslintrc.json")) || existsSync(join(cwd, ".eslintrc.js")) || existsSync(join(cwd, "eslint.config.js")) || existsSync(join(cwd, "eslint.config.mjs"))) {
info.conventions.push("ESLint configured");
}
if (existsSync(join(cwd, ".prettierrc")) || existsSync(join(cwd, ".prettierrc.json")) || existsSync(join(cwd, "prettier.config.js"))) {
info.conventions.push("Prettier configured");
}
if (existsSync(join(cwd, ".editorconfig"))) {
info.conventions.push("EditorConfig");
}
if (existsSync(join(cwd, "Dockerfile")) || existsSync(join(cwd, "docker-compose.yml"))) {
info.conventions.push("Docker");
}
if (existsSync(join(cwd, ".github"))) {
info.conventions.push("GitHub Actions CI");
}
return info;
}
// ============================================================================
// MARKDOWN GENERATOR
// ============================================================================
function generateMarkdown(info: ProjectInfo): string {
const lines: string[] = [];
lines.push(`# ${info.name}`);
lines.push("");
if (info.description) {
lines.push(info.description);
lines.push("");
}
lines.push("## Stack");
if (info.language) lines.push(`- Language: ${info.language}`);
if (info.framework) lines.push(`- Framework: ${info.framework}`);
if (info.buildCmd) lines.push(`- Build: \`${info.buildCmd}\``);
if (info.testCmd) lines.push(`- Test: \`${info.testCmd}\``);
lines.push("");
if (info.srcDir || info.testDir) {
lines.push("## Key Paths");
if (info.srcDir) lines.push(`- Source: \`${info.srcDir}\``);
if (info.testDir) lines.push(`- Tests: \`${info.testDir}\``);
lines.push("");
}
if (info.conventions.length > 0) {
lines.push("## Conventions");
for (const c of info.conventions) {
lines.push(`- ${c}`);
}
lines.push("");
}
if (info.gitRemote) {
lines.push("## Repository");
lines.push(`- ${info.gitRemote}`);
lines.push("");
}
return lines.join("\n");
}
// ============================================================================
// PUBLIC API
// ============================================================================
/**
* Standalone CLI: `whale init`
*/
export async function runInit(): Promise<void> {
const cwd = process.cwd();
const whaleDir = join(cwd, ".whale");
const outPath = join(whaleDir, "CLAUDE.md");
if (existsSync(outPath)) {
console.log(` .whale/CLAUDE.md already exists. Delete it first to regenerate.`);
return;
}
const info = detectProject(cwd);
const markdown = generateMarkdown(info);
if (!existsSync(whaleDir)) mkdirSync(whaleDir, { recursive: true });
writeFileSync(outPath, markdown, "utf-8");
console.log(` Created .whale/CLAUDE.md`);
console.log(` Detected: ${[info.language, info.framework].filter(Boolean).join(" + ") || "unknown stack"}`);
console.log(` Edit the file to add project-specific instructions.`);
}
/**
* In-chat: /init
* Returns a string to display as an assistant message.
*/
export async function runInitInline(): Promise<string> {
const cwd = process.cwd();
const whaleDir = join(cwd, ".whale");
const outPath = join(whaleDir, "CLAUDE.md");
if (existsSync(outPath)) {
return " .whale/CLAUDE.md already exists. Delete it first to regenerate.";
}
const info = detectProject(cwd);
const markdown = generateMarkdown(info);
if (!existsSync(whaleDir)) mkdirSync(whaleDir, { recursive: true });
writeFileSync(outPath, markdown, "utf-8");
const stack = [info.language, info.framework].filter(Boolean).join(" + ") || "unknown stack";
return ` Created .whale/CLAUDE.md\n Detected: ${stack}\n Edit the file to add project-specific instructions.`;
}