import { mkdir, writeFile, readFile, stat } from "node:fs/promises"; import { join } from "node:path"; import { basename, dirname } from "node:path"; import { existsSync as existsSyncWrap, readFileSync as readFileSyncWrap } from "node:fs"; import { DEFAULT_CONFIG, ConfigSchema, saveConfig, PACKAGE_MANIFEST_FILE, DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR } from "@metaobjectsdev/sdk"; import { assemble, resolveAgentContextRoot, planScaffold, AGENT_CONTEXT_MANIFEST_PATH, type Manifest, type Stack, } from "@metaobjectsdev/sdk/agent-context"; import { resolveStack } from "../lib/detect-stack.js"; import { parseInitArgs } from "../lib/args.js"; import { log } from "../lib/log.js"; import { cliVersion } from "../lib/version.js"; import { findWranglerConfig, parseWranglerConfig } from "@metaobjectsdev/migrate-ts"; import { readReferenceTemplate, REFERENCE_GENERATOR_NAMES } from "@metaobjectsdev/codegen-ts"; // ADR-0034 scaffold-and-own — `meta init` copies the codegen reference templates into // the consumer's repo so they OWN them; metaobjects.config.ts imports them locally. const OWNED_GENERATORS_DIR = "codegen/generators"; const META_COMMON_JSON = JSON.stringify( { metadata: { package: "", children: [] as unknown[], }, }, null, 2, ) + "\n"; // Issue #75 — a multi-target codegen config can route a target's outDir under // `.metaobjects//src/generated/`. That output is the regenerable // shadow (the canonical output lives at the configured outDir; re-running // `meta gen` recreates the shadow), so it must NOT be committed by default. We // ignore the per-target generated shadow with a narrow `*/src/generated/` // pattern, then explicitly re-include `migrations/` and `config.json` so the // tracked artifacts are never swept up even if a future broad pattern were added. const METAOBJECTS_GITIGNORE_BODY = `.gen-state/ # Per-target codegen output routed under .metaobjects// is regenerable # (re-run \`meta gen\`); never commit it. The canonical output is your configured # outDir, not this shadow. */src/generated/ # These ARE meant to be tracked — keep them even if a broad pattern matches. !migrations/ !config.json !package.meta.json `; // A minimal root .gitignore for a fresh project — only written when none exists, // never clobbering the user's own. Keeps a `git add -A` right after `meta init` // from staging node_modules/, a local dev sqlite file, or build output. const ROOT_GITIGNORE_BODY = `# Dependencies node_modules/ # Local dev database *.sqlite *.sqlite-journal *.db # Build output dist/ *.tsbuildinfo `; function buildMetaobjectsConfigBody(dialect: "sqlite" | "postgres" | "d1" = "sqlite"): string { return `import { defineConfig } from "@metaobjectsdev/cli"; // Owned codegen generators (ADR-0034 scaffold-and-own). \`meta init\` copied these // reference templates into ./codegen/generators/ — they are YOURS to edit, and // \`meta gen\` runs from these local copies, not from the package. Read each file's // header doc-block for what it emits and how to customize it. import { entityFile } from "./codegen/generators/entity.js"; import { queriesFile } from "./codegen/generators/queries.js"; import { routesFile } from "./codegen/generators/routes.js"; import { barrel } from "./codegen/generators/barrel.js"; export default defineConfig({ outDir: "src/generated", extStyle: "js", // ".js"-extensioned relative imports — safe under Node ESM / tsc nodenext AND bundlers dbImport: "../db", dialect: "${dialect}", apiPrefix: "", // set to "/api" if your routes mount under /api generators: [ entityFile(), queriesFile(), routesFile(), barrel(), ], docs: { outDir: "./docs", // model + api surfaces both land here (run: meta docs) layout: "flat", // or "package" for multi-package models surfaces: ["model", "api"], }, }); `; } const NEXT_STEPS = ` Initialized metaobjects/ + .metaobjects/ + metaobjects.config.ts Codegen generators copied to codegen/generators/ — they're YOURS to edit (ADR-0034 scaffold-and-own). Next steps: 1. Author entities under metaobjects/ (start from the scaffolded meta.common.json) 2. meta gen # generate idiomatic TypeScript from your entities meta gen --dry-run # ...preview without writing 3. meta docs # neutral model + API docs 4. Create your tables: meta migrate --from-db --db file:dev.sqlite --dialect sqlite --slug init --apply Ship in later sub-projects: meta ingest (propose entities from existing code), meta serve (local viewer), meta install-hooks (MCP server + Claude Code hooks). `; export interface InitOptions { cwd: string; force?: boolean; quiet?: boolean; printOnly?: boolean; refreshDocs?: boolean; d1?: boolean; servers?: string[]; clients?: string[]; noSkills?: boolean; wireRoot?: boolean; /** Scaffold ONLY the agent-context (always-on + skills + root wiring), skipping the metaobjects/ project scaffold — for dropping context into an existing/polyglot repo. */ docsOnly?: boolean; } export interface InitResult { created: string[]; preserved: string[]; warnings: string[]; } async function readManifest(cwd: string): Promise { const p = join(cwd, AGENT_CONTEXT_MANIFEST_PATH); if (!(await fileExists(p))) return undefined; try { return JSON.parse(await readFile(p, "utf8")) as Manifest; } catch { return undefined; } } /** * Walk up from `start` looking for a `.git` directory; return the repo root, or * undefined when `start` is not inside a git working tree. (`.git` can be a file * in worktrees/submodules — accept either a dir or a file.) */ function findGitRoot(start: string): string | undefined { let dir = start; // eslint-disable-next-line no-constant-condition while (true) { if (existsSyncWrap(join(dir, ".git"))) return dir; const parent = dirname(dir); if (parent === dir) return undefined; // reached filesystem root dir = parent; } } /** * Issue #77 — Claude Code discovers `.claude/skills/` only from cwd + ANCESTOR * dirs + the user level; it never walks DOWN into subdirs. So scaffolding the * agent-context into a monorepo subdir means a root-launched session won't load * the skills (the common case). When the init dir is inside a git repo whose * root is an ANCESTOR (i.e. a subdir init), warn and point the user at the repo * root. The metadata/config/migrations correctly stay in the subdir regardless. */ function warnIfMonorepoSubdir(opts: InitOptions, result: InitResult): void { if (opts.noSkills) return; // no skills written → nothing to warn about const gitRoot = findGitRoot(opts.cwd); if (gitRoot === undefined || gitRoot === opts.cwd) return; // repo root or non-git → fine const lang = opts.servers && opts.servers.length > 0 ? opts.servers[0]! : ""; result.warnings.push( "agent-context skills scaffolded into a monorepo subdir won't be discovered from a " + "root-launched session (Claude Code only walks cwd + ancestors). Scaffold the context " + `at the repo root instead: cd && meta init --docs-only --server ${lang}`, ); } /** * Resolve the stack for agent-context (re)scaffolding. Precedence: * 1. explicit --server/--client overrides — the user is (re)declaring the stack; * 2. the stack persisted in the prior manifest — ground truth from the last * init/refresh, reused so a correct multi-package stack line is never REGRESSED * by re-detecting from a root-only probe (issue #163: a monorepo's sibling- * package client and its Maven-built Kotlin are invisible at the root); * 3. best-effort detection from the root probe — a fresh project with no prior. * This governs EVERY path that runs writeAgentContext — refresh, `init --force`, and * `--docs-only` — so a declared stack survives on all of them unless the user passes * explicit overrides. resolveStack filters servers/clients to the valid vocabularies * and self-detects when handed empty arrays, so passing the manifest's persisted * string[]s (or nothing) straight through is safe — no need to special-case an empty * or absent prior. */ function stackForAgentContext(opts: InitOptions, prior: Manifest | undefined): Stack { const hasOverride = (opts.servers?.length ?? 0) > 0 || (opts.clients?.length ?? 0) > 0; const overrides = hasOverride ? { servers: opts.servers ?? [], clients: opts.clients ?? [] } : { servers: prior?.servers ?? [], clients: prior?.clients ?? [] }; return resolveStack(opts.cwd, overrides); } async function writeAgentContext(opts: InitOptions, result: InitResult): Promise { warnIfMonorepoSubdir(opts, result); const prior = await readManifest(opts.cwd); const stack = stackForAgentContext(opts, prior); let assembled = assemble({ contentRoot: resolveAgentContextRoot(), stack }); if (opts.noSkills) assembled = assembled.filter((f) => !f.path.startsWith(".claude/skills/")); const decision = planScaffold({ stack, assembled, prior, readCurrent: (rel) => { const abs = join(opts.cwd, rel); return existsSyncWrap(abs) ? readFileSyncWrap(abs, "utf8") : undefined; }, generatedBy: cliVersion(), }); // --force: overwrite hand-edited docs in place rather than parking the fresh copy // at .new (issue #163 — a forced (re)scaffold means "I mean it"; applies to // refresh and full-init alike). planScaffold already hashed every assembled file // into the manifest, so the in-place write stays tracked and a later non-forced // refresh sees it as unmodified. const writes = opts.force ? [...decision.writes, ...decision.conflicts.map((c) => ({ path: c.path, contents: c.contents }))] : decision.writes; const conflicts = opts.force ? [] : decision.conflicts; for (const w of writes) { const abs = join(opts.cwd, w.path); await mkdir(dirname(abs), { recursive: true }); await writeFile(abs, w.contents, "utf8"); result.created.push(w.path); } for (const c of conflicts) { const abs = join(opts.cwd, c.newPath); await mkdir(dirname(abs), { recursive: true }); await writeFile(abs, c.contents, "utf8"); result.created.push(c.newPath); result.warnings.push(`${c.path} appears hand-edited; refreshed version written to ${c.newPath}`); } const manifestAbs = join(opts.cwd, AGENT_CONTEXT_MANIFEST_PATH); await mkdir(dirname(manifestAbs), { recursive: true }); await writeFile(manifestAbs, JSON.stringify(decision.manifest, null, 2) + "\n", "utf8"); for (const orphan of decision.removed) { result.warnings.push(`${orphan} is no longer part of this stack; orphaned (safe to delete).`); } if (opts.wireRoot) await wireRootMemory(opts.cwd, result); } const ROOT_IMPORT_LINE = "@.metaobjects/AGENTS.md"; async function wireRootMemory(cwd: string, result: InitResult): Promise { const claudePath = join(cwd, "CLAUDE.md"); const agentsPath = join(cwd, "AGENTS.md"); const claudeExists = await fileExists(claudePath); const agentsExists = await fileExists(agentsPath); // If neither root memory file exists, create CLAUDE.md (Claude Code's canonical) with the import. if (!claudeExists && !agentsExists) { await writeFile(claudePath, `# Project memory\n\n${ROOT_IMPORT_LINE}\n`, "utf8"); result.created.push("CLAUDE.md (created with MetaObjects @import)"); return; } // Otherwise append the import to whichever exist (idempotent — never double-add). for (const [path, exists] of [[claudePath, claudeExists], [agentsPath, agentsExists]] as const) { if (!exists) continue; const body = await readFile(path, "utf8"); if (body.includes(ROOT_IMPORT_LINE)) continue; await writeFile(path, `${body.replace(/\n*$/, "\n")}\n${ROOT_IMPORT_LINE}\n`, "utf8"); result.warnings.push(`wired ${ROOT_IMPORT_LINE} into ${path.endsWith("AGENTS.md") ? "AGENTS.md" : "CLAUDE.md"} so the MetaObjects context loads`); } } /** * ADR-0034 — copy the codegen reference templates into the consumer's repo at * `codegen/generators/.ts` so they own them. Each file is written only if absent, * so a re-run with --force never clobbers a hand-edited generator. The scaffolded * metaobjects.config.ts imports these local copies (not the package `/generators` export). */ async function writeOwnedGenerators(opts: InitOptions, result: InitResult): Promise { const dir = join(opts.cwd, OWNED_GENERATORS_DIR); await mkdir(dir, { recursive: true }); for (const name of REFERENCE_GENERATOR_NAMES) { const rel = `${OWNED_GENERATORS_DIR}/${name}.ts`; const abs = join(dir, `${name}.ts`); if (await fileExists(abs)) { result.preserved.push(rel); continue; } await writeFile(abs, readReferenceTemplate(name), "utf8"); result.created.push(rel); } } export async function init(opts: InitOptions): Promise { const result: InitResult = { created: [], preserved: [], warnings: [] }; const agentDir = join(opts.cwd, DEFAULT_METAOBJECTS_DIR); const metaobjectsDir = join(opts.cwd, DEFAULT_METADATA_DIR); const agentDirExists = await dirExists(agentDir); const metaobjectsExists = await dirExists(metaobjectsDir); const exists = agentDirExists || metaobjectsExists; if (opts.docsOnly) { // Agent-context only: scaffold the always-on + skills + root wiring, never the metaobjects/ project. await writeAgentContext(opts, result); return result; } if (opts.refreshDocs && exists) { // Refresh-only path: (re)write the agent-context docs and NOTHING else — never // the project scaffold (metaobjects/, config.json, codegen/generators/, // metaobjects.config.ts). `--force` on this path means "overwrite hand-edited // docs in place instead of writing .new" (handled in writeAgentContext), // NOT a full re-init — so refresh must short-circuit BEFORE the scaffold path // even when --force is set (issue #163). A refresh on a not-yet-initialized // repo (!exists) still falls through to a full init, matching prior behavior. await writeAgentContext(opts, result); return result; } if (exists && !opts.force && !opts.refreshDocs) { throw new Error( "metaobjects/ or .metaobjects/ already exists; use --force to overwrite scaffold files (existing records are preserved), or --refresh-docs to update only agent docs", ); } const dirs = [ DEFAULT_METADATA_DIR, DEFAULT_METAOBJECTS_DIR, `${DEFAULT_METAOBJECTS_DIR}/.gen-state`, ]; if (opts.printOnly) { for (const d of dirs) result.created.push(d); result.created.push( "metaobjects/meta.common.json", ".metaobjects/config.json", ".metaobjects/.gitignore", `.metaobjects/${PACKAGE_MANIFEST_FILE}`, ); result.created.push(".metaobjects/AGENTS.md", ".metaobjects/CLAUDE.md", ".claude/skills/metaobjects-*", AGENT_CONTEXT_MANIFEST_PATH); for (const name of REFERENCE_GENERATOR_NAMES) result.created.push(`${OWNED_GENERATORS_DIR}/${name}.ts`); result.created.push("metaobjects.config.ts", ".gitignore"); return result; } for (const d of dirs) { await mkdir(join(opts.cwd, d), { recursive: true }); if (!result.created.includes(d)) result.created.push(d); } // metaobjects/meta.common.json — placeholder, only if absent const commonJsonPath = join(metaobjectsDir, "meta.common.json"); if (!(await fileExists(commonJsonPath))) { await writeFile(commonJsonPath, META_COMMON_JSON, "utf8"); result.created.push("metaobjects/meta.common.json"); } else { result.preserved.push("metaobjects/meta.common.json"); } // .metaobjects/config.json const freshConfig = opts.d1 ? ConfigSchema.parse({ ...DEFAULT_CONFIG, migrate: buildD1MigrateBlock(opts.cwd) }) : DEFAULT_CONFIG; if (agentDirExists) { const configPath = join(agentDir, "config.json"); let priorContent: string | undefined; try { priorContent = await readFile(configPath, "utf8"); const parsed = ConfigSchema.parse(JSON.parse(priorContent)); const merged = ConfigSchema.parse({ ...DEFAULT_CONFIG, ...parsed }); // When a valid .metaobjects/config.json already exists and the user passes --force, // we preserve the existing config and only re-scaffold support files. The --d1 flag // only takes effect on fresh inits — retro-fitting D1 onto an existing project is // the user's job (edit migrate.dialect and migrate.d1 in config.json directly). await saveConfig(agentDir, merged); result.preserved.push(".metaobjects/config.json"); } catch { if (priorContent !== undefined) { log.warn("existing .metaobjects/config.json was invalid — writing fresh defaults. Prior content:"); log.warn(priorContent); result.warnings.push("invalid .metaobjects/config.json replaced with defaults"); } await writeFile( join(agentDir, "config.json"), JSON.stringify(freshConfig, null, 2) + "\n", "utf8", ); if (priorContent === undefined) { result.created.push(".metaobjects/config.json"); } } } else { await writeFile( join(agentDir, "config.json"), JSON.stringify(freshConfig, null, 2) + "\n", "utf8", ); result.created.push(".metaobjects/config.json"); } // .metaobjects/.gitignore await writeFile(join(agentDir, ".gitignore"), METAOBJECTS_GITIGNORE_BODY, "utf8"); result.created.push(".metaobjects/.gitignore"); // .metaobjects/package.meta.json — scaffold v0.3 package manifest if absent const manifestPath = join(agentDir, PACKAGE_MANIFEST_FILE); if (!(await fileExists(manifestPath))) { const defaultPackageName = basename(opts.cwd); const manifestBody = { name: defaultPackageName, version: "0.1.0", extends: [] as string[], }; await writeFile(manifestPath, JSON.stringify(manifestBody, null, 2) + "\n", "utf8"); result.created.push(`.metaobjects/${PACKAGE_MANIFEST_FILE}`); } else { result.preserved.push(`.metaobjects/${PACKAGE_MANIFEST_FILE}`); } await writeAgentContext(opts, result); // ADR-0034 — scaffold the OWNED codegen generators that metaobjects.config.ts imports // locally. Done before the config so the import targets exist on first `meta gen`. await writeOwnedGenerators(opts, result); // Scaffold metaobjects.config.ts at the project root. Never overwrite if it exists. const forgeConfigPath = join(opts.cwd, "metaobjects.config.ts"); if (!(await fileExists(forgeConfigPath))) { await writeFile(forgeConfigPath, buildMetaobjectsConfigBody(opts.d1 ? "d1" : "sqlite"), "utf8"); result.created.push("metaobjects.config.ts"); } // Scaffold a minimal root .gitignore ONLY when the project has none — never // clobber a user's existing one (they may have their own rules). const rootGitignorePath = join(opts.cwd, ".gitignore"); if (!(await fileExists(rootGitignorePath))) { await writeFile(rootGitignorePath, ROOT_GITIGNORE_BODY, "utf8"); result.created.push(".gitignore"); } else { result.preserved.push(".gitignore"); } return result; } function buildD1MigrateBlock(cwd: string): Record { const block: Record = { dialect: "d1" }; const cfgPath = findWranglerConfig(cwd); if (cfgPath !== undefined) { try { const parsed = parseWranglerConfig(cfgPath); if (parsed.d1Bindings.length === 1) { block.d1 = { binding: parsed.d1Bindings[0]!.binding }; } // Multi-binding case: omit d1 entirely. User picks the binding later with // `meta migrate --d1 ` (which prompts with the available names). } catch { // Parse failed; leave d1 sub-block absent. } } return block; } export function nextStepsBlock(): string { return NEXT_STEPS; } async function dirExists(p: string): Promise { try { const s = await stat(p); return s.isDirectory(); } catch { return false; } } async function fileExists(p: string): Promise { try { const s = await stat(p); return s.isFile(); } catch { return false; } } export async function initCommand(args: string[], cwd: string): Promise { let flags; try { flags = parseInitArgs(args); } catch (err) { log.error((err as Error).message); return 2; } try { const result = await init({ cwd, force: flags.force, quiet: flags.quiet, printOnly: flags.printOnly, refreshDocs: flags.refreshDocs, d1: flags.d1, servers: flags.servers, clients: flags.clients, noSkills: flags.noSkills, wireRoot: flags.wireRoot, docsOnly: flags.docsOnly, }); if (flags.printOnly) { log.info("Would create:"); for (const path of result.created) log.info(` ${path}`); return 0; } if (!flags.quiet) { if (flags.docsOnly) { log.info(`Scaffolded the MetaObjects agent context (${result.created.length} files): .metaobjects/AGENTS.md + .claude/skills/metaobjects-*.`); for (const w of result.warnings) log.info(` ${w}`); log.info("Re-run --docs-only --refresh-docs to update; --no-wire-root to skip the root CLAUDE.md @import."); } else { log.info(nextStepsBlock()); // Surface any scaffold warnings (e.g. the #77 monorepo-subdir agent-context // discovery warning) — these are otherwise dropped on the normal init path. for (const w of result.warnings) log.warn(w); } } return 0; } catch (err) { log.error((err as Error).message); return 1; } }