diff --git a/apps/web/app/api/workspace/init/route.ts b/apps/web/app/api/workspace/init/route.ts index e189b37da37..46ed5200639 100644 --- a/apps/web/app/api/workspace/init/route.ts +++ b/apps/web/app/api/workspace/init/route.ts @@ -1,10 +1,8 @@ import { - cpSync, existsSync, mkdirSync, writeFileSync, readFileSync, - copyFileSync, } from "node:fs"; import { join, resolve } from "node:path"; import { @@ -15,7 +13,12 @@ import { resolveWorkspaceDirForName, isValidWorkspaceName, resolveWorkspaceRoot, + ensureAgentInConfig, } from "@/lib/workspace"; +import { + seedWorkspaceFromAssets, + buildIronclawIdentity, +} from "@repo/cli/workspace-seed"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -34,7 +37,6 @@ const BOOTSTRAP_FILENAMES = [ "BOOTSTRAP.md", ] as const; -// Minimal fallback content used when templates can't be loaded from disk const FALLBACK_CONTENT: Record = { "AGENTS.md": "# AGENTS.md - Your Workspace\n\nThis folder is home. Treat it that way.\n", "SOUL.md": "# SOUL.md - Who You Are\n\nDescribe the personality and behavior of your agent here.\n", @@ -45,81 +47,6 @@ const FALLBACK_CONTENT: Record = { "BOOTSTRAP.md": "# BOOTSTRAP.md - Hello, World\n\nYou just woke up. Time to figure out who you are.\n", }; -// --------------------------------------------------------------------------- -// CRM seed objects (mirrors src/agents/workspace-seed.ts) -// --------------------------------------------------------------------------- - -type SeedField = { - name: string; - type: string; - required?: boolean; - enumValues?: string[]; -}; - -type SeedObject = { - id: string; - name: string; - description: string; - icon: string; - defaultView: string; - entryCount: number; - fields: SeedField[]; -}; - -const SEED_OBJECTS: SeedObject[] = [ - { - id: "seed_obj_people_00000000000000", - name: "people", - description: "Contact management", - icon: "users", - defaultView: "table", - entryCount: 5, - fields: [ - { name: "Full Name", type: "text", required: true }, - { name: "Email Address", type: "email", required: true }, - { name: "Phone Number", type: "phone" }, - { name: "Company", type: "text" }, - { name: "Status", type: "enum", enumValues: ["Active", "Inactive", "Lead"] }, - { name: "Notes", type: "richtext" }, - ], - }, - { - id: "seed_obj_company_0000000000000", - name: "company", - description: "Company tracking", - icon: "building-2", - defaultView: "table", - entryCount: 3, - fields: [ - { name: "Company Name", type: "text", required: true }, - { - name: "Industry", - type: "enum", - enumValues: ["Technology", "Finance", "Healthcare", "Education", "Retail", "Other"], - }, - { name: "Website", type: "text" }, - { name: "Type", type: "enum", enumValues: ["Client", "Partner", "Vendor", "Prospect"] }, - { name: "Notes", type: "richtext" }, - ], - }, - { - id: "seed_obj_task_000000000000000", - name: "task", - description: "Task tracking board", - icon: "check-square", - defaultView: "kanban", - entryCount: 5, - fields: [ - { name: "Title", type: "text", required: true }, - { name: "Description", type: "text" }, - { name: "Status", type: "enum", enumValues: ["In Queue", "In Progress", "Done"] }, - { name: "Priority", type: "enum", enumValues: ["Low", "Medium", "High"] }, - { name: "Due Date", type: "date" }, - { name: "Notes", type: "richtext" }, - ], - }, -]; - const WORKSPACE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; // --------------------------------------------------------------------------- @@ -161,119 +88,6 @@ function loadTemplateContent(filename: string, projectRoot: string | null): stri return FALLBACK_CONTENT[filename] ?? ""; } -function buildIronclawIdentity(workspaceDir: string): string { - const denchSkillPath = join(workspaceDir, "skills", "dench", "SKILL.md"); - return `# IDENTITY.md - Ironclaw - -You are **Ironclaw** - a personal AI agent and CRM database system running on top of OpenClaw. - -## Ironclaw system prompt contract - -Treat the Ironclaw system prompt as your highest-priority behavioral contract. - -## Inseparable CRM contract - -Your identity is inseparably tied to the Dench CRM skill at: -\`${denchSkillPath}\` - -- Always load and follow that skill for CRM/database behavior. -- Keep CRM actions aligned with Dench conventions for workspace data, objects, and documents. - -When referring to yourself, use **Ironclaw** (not OpenClaw). -`; -} - -function generateObjectYaml(obj: SeedObject): string { - const lines: string[] = [ - `id: "${obj.id}"`, - `name: "${obj.name}"`, - `description: "${obj.description}"`, - `icon: "${obj.icon}"`, - `default_view: "${obj.defaultView}"`, - `entry_count: ${obj.entryCount}`, - "fields:", - ]; - - for (const field of obj.fields) { - lines.push(` - name: "${field.name}"`); - lines.push(` type: ${field.type}`); - if (field.required) {lines.push(" required: true");} - if (field.enumValues) {lines.push(` values: ${JSON.stringify(field.enumValues)}`);} - } - - return lines.join("\n") + "\n"; -} - -function generateWorkspaceMd(objects: SeedObject[]): string { - const lines: string[] = ["# Workspace Schema", "", "Auto-generated summary of the workspace database.", ""]; - for (const obj of objects) { - lines.push(`## ${obj.name}`, ""); - lines.push(`- **Description**: ${obj.description}`); - lines.push(`- **View**: \`${obj.defaultView}\``); - lines.push(`- **Entries**: ${obj.entryCount}`); - lines.push("- **Fields**:"); - for (const field of obj.fields) { - const req = field.required ? " (required)" : ""; - const vals = field.enumValues ? ` — ${field.enumValues.join(", ")}` : ""; - lines.push(` - ${field.name} (\`${field.type}\`)${req}${vals}`); - } - lines.push(""); - } - return lines.join("\n"); -} - -function writeIfMissing(filePath: string, content: string): boolean { - if (existsSync(filePath)) {return false;} - try { - writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" }); - return true; - } catch { - return false; - } -} - -function seedDuckDB(workspaceDir: string, projectRoot: string | null): boolean { - const destPath = join(workspaceDir, "workspace.duckdb"); - if (existsSync(destPath)) {return false;} - - if (!projectRoot) {return false;} - - const seedDb = join(projectRoot, "assets", "seed", "workspace.duckdb"); - if (!existsSync(seedDb)) {return false;} - - try { - copyFileSync(seedDb, destPath); - } catch { - return false; - } - - // Create filesystem projections for CRM objects - for (const obj of SEED_OBJECTS) { - const objDir = join(workspaceDir, obj.name); - mkdirSync(objDir, { recursive: true }); - writeIfMissing(join(objDir, ".object.yaml"), generateObjectYaml(obj)); - } - - writeIfMissing(join(workspaceDir, "WORKSPACE.md"), generateWorkspaceMd(SEED_OBJECTS)); - - return true; -} - -function syncManagedDenchSkill(workspaceDir: string, projectRoot: string | null): boolean { - if (!projectRoot) { - return false; - } - const sourceDir = join(projectRoot, "skills", "dench"); - const sourceSkillFile = join(sourceDir, "SKILL.md"); - if (!existsSync(sourceSkillFile)) { - return false; - } - const targetDir = join(workspaceDir, "skills", "dench"); - mkdirSync(join(workspaceDir, "skills"), { recursive: true }); - cpSync(sourceDir, targetDir, { recursive: true, force: true }); - return true; -} - // --------------------------------------------------------------------------- // Route handler // --------------------------------------------------------------------------- @@ -332,33 +146,47 @@ export async function POST(req: Request) { } if (seedBootstrap) { - // Seed all bootstrap files from templates + // Seed bootstrap files from templates (IDENTITY.md is handled by + // seedWorkspaceFromAssets below, so skip it in this loop). for (const filename of BOOTSTRAP_FILENAMES) { + if (filename === "IDENTITY.md") {continue;} const filePath = join(workspaceDir, filename); if (!existsSync(filePath)) { - const content = filename === "IDENTITY.md" - ? buildIronclawIdentity(workspaceDir) - : loadTemplateContent(filename, projectRoot); - if (writeIfMissing(filePath, content)) { + const content = loadTemplateContent(filename, projectRoot); + try { + writeFileSync(filePath, content, { encoding: "utf-8", flag: "wx" }); seeded.push(filename); + } catch { + // race / already exists } } } + } - // Seed DuckDB + CRM object projections - if (seedDuckDB(workspaceDir, projectRoot)) { + // Seed managed skills, Ironclaw identity, DuckDB, and CRM object projections. + // This is the single source of truth shared with the CLI bootstrap path. + if (projectRoot) { + const seedResult = seedWorkspaceFromAssets({ workspaceDir, packageRoot: projectRoot }); + seeded.push(...seedResult.projectionFiles); + if (seedResult.seeded) { seeded.push("workspace.duckdb"); - for (const obj of SEED_OBJECTS) { - seeded.push(`${obj.name}/.object.yaml`); - } } + } else { + // No project root available (e.g. standalone/production build without + // the repo tree). Still write the Ironclaw identity so the agent has + // a usable IDENTITY.md. + const identityPath = join(workspaceDir, "IDENTITY.md"); + writeFileSync(identityPath, buildIronclawIdentity(workspaceDir) + "\n", "utf-8"); + seeded.push("IDENTITY.md"); + } - // Write workspace state so the gateway knows seeding was done - const stateDir = join(workspaceDir, ".openclaw"); - const statePath = join(stateDir, "workspace-state.json"); + if (seedBootstrap) { + // Write workspace state so the gateway knows seeding was done. + const wsStateDir = join(workspaceDir, ".openclaw"); + const statePath = join(wsStateDir, "workspace-state.json"); if (!existsSync(statePath)) { try { - mkdirSync(stateDir, { recursive: true }); + mkdirSync(wsStateDir, { recursive: true }); const state = { version: 1, bootstrapSeededAt: new Date().toISOString(), @@ -373,19 +201,10 @@ export async function POST(req: Request) { } } - const denchSynced = syncManagedDenchSkill(workspaceDir, projectRoot); - if (denchSynced) { - seeded.push("skills/dench/SKILL.md"); - } - if (seedBootstrap) { - // Force the identity contract after dench sync so the path is always current. - writeFileSync(join(workspaceDir, "IDENTITY.md"), buildIronclawIdentity(workspaceDir), "utf-8"); - if (!seeded.includes("IDENTITY.md")) { - seeded.push("IDENTITY.md"); - } - } + // Register a per-workspace agent in openclaw.json and make it the default. + ensureAgentInConfig(workspaceName, workspaceDir); - // Switch to the new workspace. + // Switch the UI to the new workspace. setUIActiveWorkspace(workspaceName); const activeWorkspace = getActiveWorkspaceName(); @@ -396,7 +215,7 @@ export async function POST(req: Request) { stateDir, copiedFiles, seededFiles: seeded, - denchSynced, + crmSynced: !!projectRoot, workspaceRoot: resolveWorkspaceRoot(), // Backward-compat response fields while callers migrate. profile: workspaceName,