From 4c4b348c1eebb799dad1424204e481a14aad0b95 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Mon, 6 Apr 2026 10:27:48 -0700 Subject: [PATCH 1/2] =?UTF-8?q?feat(project):=20local=20dev=20agents=20?= =?UTF-8?q?=E2=80=94=20auto-detect=20project,=20create=20agent=20team,=20s?= =?UTF-8?q?tart=20dev=20server?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When running `bunx decocms` from a project directory, the system now: - Detects the project stack (Next.js, Fresh, Astro, Vite, Remix, Nuxt, etc.) - Shows project name and path in the topbar with dev server status - Auto-creates a team of specialized agents (Overview, Dev Server, Dependencies, Performance, Framework Expert, Deploy) - Starts the project's dev server and shows a live preview in an iframe - Groups Claude Code / Codex as "Local models" with a highlighted card on the provider selection screen - Improves onboarding copy: "decocms is ready for agents" instead of "No model provider connected" - Fixes sidebar agent pinning to sync new server-pinned agents into localStorage New files: project/scanner.ts, project/agent-templates.ts, project/bootstrap.ts, project/dev-server.ts, project/state.ts, api/routes/project.ts, web/components/preview-panel.tsx, topbar-project-info.tsx, use-project-info.ts Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/mesh/src/api/app.ts | 2 + apps/mesh/src/api/routes/project.ts | 143 +++++++++ apps/mesh/src/api/routes/public-config.ts | 15 + apps/mesh/src/cli.ts | 18 ++ apps/mesh/src/cli/commands/dev.ts | 5 + apps/mesh/src/cli/commands/serve.ts | 2 + apps/mesh/src/index.ts | 37 +++ apps/mesh/src/project/agent-templates.ts | 287 +++++++++++++++++ apps/mesh/src/project/bootstrap.ts | 131 ++++++++ apps/mesh/src/project/dev-server.ts | 189 +++++++++++ apps/mesh/src/project/scanner.ts | 295 ++++++++++++++++++ apps/mesh/src/project/state.ts | 18 ++ apps/mesh/src/settings/resolve-config.ts | 3 + apps/mesh/src/settings/types.ts | 5 + .../chat/no-ai-provider-empty-state.tsx | 34 +- .../src/web/components/chat/select-model.tsx | 5 +- .../web/components/chat/side-panel-chat.tsx | 9 +- .../src/web/components/home/agents-list.tsx | 74 +++-- .../mesh/src/web/components/preview-panel.tsx | 102 ++++++ .../web/components/project-setup-sequence.tsx | 233 ++++++++++++++ .../web/components/topbar-project-info.tsx | 77 +++++ apps/mesh/src/web/hooks/use-layout-state.ts | 3 +- apps/mesh/src/web/hooks/use-pinned-agents.ts | 25 +- apps/mesh/src/web/hooks/use-project-info.ts | 65 ++++ .../src/web/layouts/agent-shell-layout.tsx | 25 +- apps/mesh/src/web/lib/query-keys.ts | 4 + apps/mesh/src/web/routes/agent-home.tsx | 9 + .../web/views/settings/org-ai-providers.tsx | 32 ++ 28 files changed, 1799 insertions(+), 48 deletions(-) create mode 100644 apps/mesh/src/api/routes/project.ts create mode 100644 apps/mesh/src/project/agent-templates.ts create mode 100644 apps/mesh/src/project/bootstrap.ts create mode 100644 apps/mesh/src/project/dev-server.ts create mode 100644 apps/mesh/src/project/scanner.ts create mode 100644 apps/mesh/src/project/state.ts create mode 100644 apps/mesh/src/web/components/preview-panel.tsx create mode 100644 apps/mesh/src/web/components/project-setup-sequence.tsx create mode 100644 apps/mesh/src/web/components/topbar-project-info.tsx create mode 100644 apps/mesh/src/web/hooks/use-project-info.ts diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index d879b28894..8469342a53 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -47,6 +47,7 @@ import proxyRoutes from "./routes/proxy"; import { createKVRoutes } from "./routes/kv"; import { createTriggerCallbackRoutes } from "./routes/trigger-callback"; import publicConfigRoutes from "./routes/public-config"; +import projectRoutes from "./routes/project"; import filesRoutes from "./routes/files"; import selfRoutes from "./routes/self"; import { shouldSkipMeshContext, SYSTEM_PATHS } from "./utils/paths"; @@ -566,6 +567,7 @@ export async function createApp(options: CreateAppOptions = {}) { // Public Configuration (no auth required) // ============================================================================ app.route("/api/config", publicConfigRoutes); + app.route("/api/project", projectRoutes); // ============================================================================ // Better Auth Routes diff --git a/apps/mesh/src/api/routes/project.ts b/apps/mesh/src/api/routes/project.ts new file mode 100644 index 0000000000..a1bb8a420c --- /dev/null +++ b/apps/mesh/src/api/routes/project.ts @@ -0,0 +1,143 @@ +/** + * Project API Routes + * + * Exposes project scan results and dev server control endpoints. + * Only available in local mode when a project directory is detected. + */ + +import { Hono } from "hono"; +import { getScanResult } from "@/project/state"; +import { + getDevServerState, + startProjectDevServer, + stopProjectDevServer, + restartProjectDevServer, +} from "@/project/dev-server"; + +const app = new Hono(); + +/** + * GET /api/project + * Returns project scan result and dev server state + */ +app.get("/", (c) => { + const scan = getScanResult(); + if (!scan) { + return c.json({ success: false, error: "No project detected" }, 404); + } + + return c.json({ + success: true, + scan, + devServer: getDevServerState(), + }); +}); + +/** + * GET /api/project/dev-server + * Returns just the dev server state (for lightweight polling) + */ +app.get("/dev-server", (c) => { + return c.json({ + success: true, + devServer: getDevServerState(), + }); +}); + +/** + * POST /api/project/dev-server/start + * Starts the project dev server + */ +app.post("/dev-server/start", async (c) => { + const scan = getScanResult(); + if (!scan) { + return c.json({ success: false, error: "No project detected" }, 404); + } + + await startProjectDevServer(scan); + return c.json({ success: true, devServer: getDevServerState() }); +}); + +/** + * POST /api/project/dev-server/stop + * Stops the project dev server + */ +app.post("/dev-server/stop", async (c) => { + await stopProjectDevServer(); + return c.json({ success: true, devServer: getDevServerState() }); +}); + +/** + * POST /api/project/dev-server/restart + * Restarts the project dev server + */ +app.post("/dev-server/restart", async (c) => { + await restartProjectDevServer(); + return c.json({ success: true, devServer: getDevServerState() }); +}); + +/** + * GET /api/project/dev-server/logs + * Returns dev server logs + */ +app.get("/dev-server/logs", (c) => { + const state = getDevServerState(); + return c.json({ success: true, logs: state.logs }); +}); + +/** + * Reverse proxy to the project dev server, stripping frame-blocking headers. + * This allows the preview iframe to load the dev server content. + * Handles both /api/project/preview and /api/project/preview/* paths. + */ +app.all("/preview", (c) => handlePreviewProxy(c)); +app.all("/preview/*", (c) => handlePreviewProxy(c)); + +async function handlePreviewProxy(c: { + req: { path: string; url: string; method: string; raw: Request }; + text: (body: string, status: number) => Response; +}) { + const devServer = getDevServerState(); + if (!devServer.url) { + return c.text("Dev server not running", 503); + } + + // Reconstruct the target URL from the wildcard path + const path = c.req.path.replace(/^\/api\/project\/preview\/?/, "/"); + const targetUrl = new URL(path || "/", devServer.url); + + // Forward query string + const reqUrl = new URL(c.req.url); + targetUrl.search = reqUrl.search; + + try { + const headers = new Headers(c.req.raw.headers); + // Remove host header to avoid conflicts + headers.delete("host"); + + const response = await fetch(targetUrl.toString(), { + method: c.req.method, + headers, + body: + c.req.method !== "GET" && c.req.method !== "HEAD" + ? c.req.raw.body + : undefined, + redirect: "manual", + }); + + // Clone response and strip frame-blocking headers + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("x-frame-options"); + responseHeaders.delete("content-security-policy"); + + return new Response(response.body, { + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + }); + } catch { + return c.text("Failed to proxy to dev server", 502); + } +} + +export default app; diff --git a/apps/mesh/src/api/routes/public-config.ts b/apps/mesh/src/api/routes/public-config.ts index 3bbbe39677..0d67d35565 100644 --- a/apps/mesh/src/api/routes/public-config.ts +++ b/apps/mesh/src/api/routes/public-config.ts @@ -6,6 +6,7 @@ */ import { Hono } from "hono"; +import { basename } from "path"; import { getConfig, getThemeConfig, type ThemeConfig } from "@/core/config"; import { isLocalMode } from "@/auth/local-mode"; import { getInternalUrl } from "@/core/server-constants"; @@ -43,6 +44,15 @@ export type PublicConfig = { * Requires FIRECRAWL_API_KEY to be configured. */ brandExtractEnabled?: boolean; + /** + * Absolute path to the user's project directory. + * Only set in local mode when running inside a project. + */ + projectDir?: string; + /** + * Human-readable project name (basename of projectDir). + */ + projectName?: string; }; /** @@ -61,6 +71,11 @@ app.get("/", (c) => { ...(isLocalMode() && { internalUrl: getInternalUrl() }), ...(getSettings().enableDecoImport && { enableDecoImport: true }), brandExtractEnabled: !!getSettings().firecrawlApiKey, + ...(isLocalMode() && + getSettings().projectDir != null && { + projectDir: getSettings().projectDir as string, + projectName: basename(getSettings().projectDir as string), + }), }; return c.json({ success: true, config }); diff --git a/apps/mesh/src/cli.ts b/apps/mesh/src/cli.ts index 8a8c745b95..b8dc2efa29 100644 --- a/apps/mesh/src/cli.ts +++ b/apps/mesh/src/cli.ts @@ -15,6 +15,18 @@ import { parseArgs } from "util"; import { homedir } from "os"; import { join } from "path"; +import { existsSync } from "fs"; + +/** Check if a directory contains a project marker (package.json, deno.json, etc.) */ +function detectProjectDir(dir: string): string | undefined { + const markers = ["package.json", "deno.json", "deno.jsonc"]; + for (const marker of markers) { + if (existsSync(join(dir, marker))) { + return dir; + } + } + return undefined; +} const { values, positionals } = parseArgs({ args: process.argv.slice(2), @@ -60,6 +72,9 @@ const { values, positionals } = parseArgs({ type: "string", default: "1", }, + project: { + type: "string", + }, vibe: { type: "boolean", default: false, @@ -94,6 +109,7 @@ Server Options: Dev Options: --vite-port Vite dev server port (default: 4000) --base-url Base URL for the server + --project Project directory to manage (default: auto-detect CWD) Environment Variables: PORT Port to listen on (default: 3000) @@ -203,6 +219,7 @@ if (command === "dev") { skipMigrations: values["skip-migrations"] === true, noTui, localMode: values["no-local-mode"] !== true, + projectDir: values.project || detectProjectDir(process.cwd()), }; if (noTui) { @@ -270,6 +287,7 @@ const serveOptions = { const n = Number(values["num-threads"]); return Number.isInteger(n) && n > 0 ? n : 1; })(), + projectDir: values.project || detectProjectDir(process.cwd()), }; const noTui = values["no-tui"] === true || !process.stdout.isTTY; diff --git a/apps/mesh/src/cli/commands/dev.ts b/apps/mesh/src/cli/commands/dev.ts index 3686604bc5..fcf7f26bc6 100644 --- a/apps/mesh/src/cli/commands/dev.ts +++ b/apps/mesh/src/cli/commands/dev.ts @@ -25,6 +25,7 @@ export interface DevOptions { skipMigrations: boolean; noTui?: boolean; localMode: boolean; + projectDir?: string; } // Strip ANSI escape codes from a string @@ -102,6 +103,7 @@ export async function startDevServer( skipMigrations: options.skipMigrations, noTui: options.noTui, vitePort: options.vitePort, + projectDir: options.projectDir, }); for (const s of services) { @@ -130,6 +132,9 @@ export async function startDevServer( DECOCMS_HOME: settings.dataDir, DATA_DIR: settings.dataDir, DECO_CLI: "1", + ...(settings.projectDir + ? { DECOCMS_PROJECT_DIR: settings.projectDir } + : {}), ...(settings.baseUrl ? { BASE_URL: settings.baseUrl } : {}), }, stdio: [ diff --git a/apps/mesh/src/cli/commands/serve.ts b/apps/mesh/src/cli/commands/serve.ts index fdafe313bb..7e61fe182c 100644 --- a/apps/mesh/src/cli/commands/serve.ts +++ b/apps/mesh/src/cli/commands/serve.ts @@ -25,6 +25,7 @@ export interface ServeOptions { localMode: boolean; noTui?: boolean; numThreads?: number; + projectDir?: string; } // Strip ANSI escape codes from a string @@ -143,6 +144,7 @@ export async function startServer(options: ServeOptions): Promise { skipMigrations: options.skipMigrations, noTui: options.noTui, nodeEnv: "production", + projectDir: options.projectDir, }); for (const s of services) { diff --git a/apps/mesh/src/index.ts b/apps/mesh/src/index.ts index 48e31a3bfd..02a5597892 100644 --- a/apps/mesh/src/index.ts +++ b/apps/mesh/src/index.ts @@ -125,6 +125,43 @@ if (settings.localMode && !isWorker) { try { const seeded = await seedLocalMode(); void seeded; + + // Bootstrap project agents if running in a project directory + if (settings.projectDir) { + try { + const { getDb } = await import("./database"); + const { getLocalAdminUser } = await import("./auth/local-mode"); + const database = getDb(); + const user = await getLocalAdminUser(); + if (user) { + // Get the user's organization + const membership = await database.db + .selectFrom("member") + .select("organizationId") + .where("userId", "=", user.id) + .executeTakeFirst(); + + if (membership) { + const { bootstrapProjectAgents } = await import( + "./project/bootstrap" + ); + const { scan } = await bootstrapProjectAgents( + settings.projectDir, + membership.organizationId, + user.id, + ); + + // Auto-start the project dev server + const { startProjectDevServer } = await import( + "./project/dev-server" + ); + await startProjectDevServer(scan); + } + } + } catch (error) { + console.error("[project] Failed to bootstrap project:", error); + } + } } catch (error) { console.error("Failed to seed local mode:", error); } finally { diff --git a/apps/mesh/src/project/agent-templates.ts b/apps/mesh/src/project/agent-templates.ts new file mode 100644 index 0000000000..b4c654f2af --- /dev/null +++ b/apps/mesh/src/project/agent-templates.ts @@ -0,0 +1,287 @@ +/** + * Project Agent Templates + * + * Defines the team of agents auto-created when a project is detected. + * Each template's `instructions` is a function that interpolates scan results. + */ + +import type { ProjectScanResult, FrameworkId } from "./scanner"; + +export interface ProjectAgentTemplate { + id: string; + title: string | ((scan: ProjectScanResult) => string); + description: string; + icon: string; + instructions: (scan: ProjectScanResult) => string; + applicableWhen?: (scan: ProjectScanResult) => boolean; +} + +const FRAMEWORK_NAMES: Record = { + nextjs: "Next.js", + fresh: "Fresh (Deno)", + astro: "Astro", + vite: "Vite", + remix: "Remix", + nuxt: "Nuxt", + bun: "Bun", +}; + +export const PROJECT_AGENT_TEMPLATES: ProjectAgentTemplate[] = [ + { + id: "project-overview", + title: (scan) => `${scan.projectName}`, + description: + "Your project hub — understands the full project and routes to specialists", + icon: "icon://Globe01?color=violet", + instructions: ( + scan, + ) => `You are the project overview agent for "${scan.projectName}". + +Project directory: ${scan.projectDir} +Framework: ${scan.framework ? FRAMEWORK_NAMES[scan.framework] : "Unknown"} +Package manager: ${scan.packageManager} +Dev command: ${scan.devCommand} +${scan.deployTarget ? `Deploy target: ${scan.deployTarget}` : ""} +${scan.hasGit ? "Git repository: yes" : ""} + +You are the main agent for this project. You understand the full codebase and can help with any task. You can: +- Navigate and explain the project structure +- Help with code changes, new features, and bug fixes +- Coordinate with specialist agents (Dependencies, Performance, Deploy) +- Answer questions about the framework and architecture + +When a user asks something specific to dependencies, performance, or deployment, suggest they talk to the specialist agent for deeper analysis.`, + }, + { + id: "project-dev-server", + title: "Dev Server", + description: "Manages your dev server and shows a live preview", + icon: "icon://Terminal?color=green", + instructions: ( + scan, + ) => `You manage the local development server for "${scan.projectName}". + +Dev command: ${scan.devCommand} +Default port: ${scan.devPort} +Framework: ${scan.framework ? FRAMEWORK_NAMES[scan.framework] : "Unknown"} + +You can help with: +- Starting, stopping, and restarting the dev server +- Diagnosing build errors and dev server issues +- Understanding dev server output and logs +- Hot reload issues and cache problems +- Environment variable configuration + +The dev server preview is shown in the main panel. If the user reports the preview not loading, check the dev server logs and status.`, + }, + { + id: "project-dependencies", + title: "Dependencies", + description: "Audits outdated and vulnerable dependencies", + icon: "icon://Package?color=amber", + instructions: (scan) => `You audit dependencies for "${scan.projectName}". + +Package manager: ${scan.packageManager} +Project directory: ${scan.projectDir} + +You can help with: +- Checking for outdated dependencies +- Finding security vulnerabilities (npm audit, etc.) +- Recommending dependency upgrades with breaking change analysis +- Identifying unused dependencies +- Resolving dependency conflicts +- License compliance checking + +When analyzing dependencies: +1. Check the lock file for the current dependency tree +2. Identify outdated packages and their latest versions +3. Flag any known security vulnerabilities +4. Suggest a prioritized upgrade plan (security fixes first, then major updates) + +Package manager commands: +${scan.packageManager === "deno" ? "- deno info: Show dependency tree" : ""} +${scan.packageManager === "bun" ? "- bun outdated: Check outdated deps\n- bun update: Update deps" : ""} +${scan.packageManager === "npm" ? "- npm outdated: Check outdated deps\n- npm audit: Security audit" : ""} +${scan.packageManager === "pnpm" ? "- pnpm outdated: Check outdated deps\n- pnpm audit: Security audit" : ""} +${scan.packageManager === "yarn" ? "- yarn outdated: Check outdated deps\n- yarn audit: Security audit" : ""}`, + }, + { + id: "project-performance", + title: "Performance", + description: "PageSpeed, Lighthouse, and bundle analysis", + icon: "icon://Zap?color=cyan", + instructions: (scan) => `You analyze performance for "${scan.projectName}". + +Framework: ${scan.framework ? FRAMEWORK_NAMES[scan.framework] : "Unknown"} +Dev command: ${scan.devCommand} +Project directory: ${scan.projectDir} + +You can help with: +- Running PageSpeed / Lighthouse audits +- Bundle size analysis +- Identifying performance bottlenecks +- Image optimization recommendations +- Code splitting opportunities +- Core Web Vitals improvement +- Font loading optimization +- Third-party script analysis + +When the dev server is running, you can analyze the local site for performance issues. Provide actionable recommendations with estimated impact.`, + }, + { + id: "project-framework", + title: (scan) => + scan.framework + ? `${FRAMEWORK_NAMES[scan.framework]} Expert` + : "Framework Expert", + description: "Stack-specific guidance and best practices", + icon: "icon://BookOpen01?color=blue", + instructions: (scan) => { + const name = scan.framework + ? FRAMEWORK_NAMES[scan.framework] + : "your framework"; + return `You are a ${name} expert for "${scan.projectName}". + +Framework: ${name} +Package manager: ${scan.packageManager} +Project directory: ${scan.projectDir} + +You provide deep, framework-specific guidance: +${ + scan.framework === "nextjs" + ? `- App Router vs Pages Router patterns +- Server Components and Client Components +- Data fetching (Server Actions, Route Handlers) +- Middleware and edge runtime +- Image and font optimization +- ISR and static generation` + : "" +} +${ + scan.framework === "fresh" + ? `- Islands architecture +- Route handlers and middleware +- Preact signals for state management +- Plugin system +- Deno Deploy configuration` + : "" +} +${ + scan.framework === "astro" + ? `- Content Collections +- Islands architecture and partial hydration +- View Transitions +- SSR adapters +- Integrations (React, Vue, Svelte)` + : "" +} +${ + scan.framework === "vite" + ? `- Plugin configuration +- Build optimization +- HMR and dev server +- Library mode +- Environment variables` + : "" +} +${ + scan.framework === "remix" + ? `- Loader and Action patterns +- Nested routing +- Error boundaries +- Form handling +- Streaming` + : "" +} +${ + scan.framework === "nuxt" + ? `- Auto-imports and composables +- Nitro server engine +- Nuxt modules +- State management (Pinia) +- SEO and meta management` + : "" +} + +Help the user follow framework best practices and conventions.`; + }, + applicableWhen: (scan) => scan.framework !== null, + }, + { + id: "project-deploy", + title: (scan) => { + const targets: Record = { + vercel: "Vercel Deploy", + netlify: "Netlify Deploy", + "deno-deploy": "Deno Deploy", + cloudflare: "Cloudflare Deploy", + }; + return scan.deployTarget + ? (targets[scan.deployTarget] ?? "Deploy") + : "Deploy"; + }, + description: "Deployment management and CI/CD", + icon: "icon://Rocket01?color=rose", + instructions: (scan) => { + const target = scan.deployTarget ?? "unknown"; + return `You manage deployments for "${scan.projectName}". + +Deploy target: ${target} +${scan.hasGit ? "Git: yes — branches and PRs can trigger preview deploys" : ""} +Build command: ${scan.buildCommand ?? "not configured"} +Project directory: ${scan.projectDir} + +You can help with: +- Creating and managing deployments +- Setting up preview deploys for branches +- Environment variable management on the platform +- Build configuration and optimization +- Domain and DNS configuration +- Monitoring deployment status +- Rollback procedures +${ + target === "vercel" + ? ` +Vercel-specific: +- vercel.json configuration +- Edge and Serverless function configuration +- ISR and revalidation settings +- Vercel Analytics and Speed Insights` + : "" +} +${ + target === "netlify" + ? ` +Netlify-specific: +- netlify.toml configuration +- Netlify Functions +- Edge Functions +- Forms and Identity` + : "" +} +${ + target === "cloudflare" + ? ` +Cloudflare-specific: +- wrangler.toml configuration +- Workers and Pages +- D1 Database +- R2 Storage` + : "" +} +${ + target === "deno-deploy" + ? ` +Deno Deploy-specific: +- deployctl configuration +- KV storage +- Cron jobs +- GitHub integration` + : "" +} + +When the user asks to deploy, guide them through the process step by step.`; + }, + applicableWhen: (scan) => scan.deployTarget !== null, + }, +]; diff --git a/apps/mesh/src/project/bootstrap.ts b/apps/mesh/src/project/bootstrap.ts new file mode 100644 index 0000000000..bf32bd6efe --- /dev/null +++ b/apps/mesh/src/project/bootstrap.ts @@ -0,0 +1,131 @@ +/** + * Project Bootstrap + * + * Orchestrates project scanning and agent creation on startup. + * Runs after local-mode seeding when a projectDir is detected. + */ + +import { join } from "path"; +import { scanProject, type ProjectScanResult } from "./scanner"; +import { PROJECT_AGENT_TEMPLATES } from "./agent-templates"; +import { setScanResult } from "./state"; +import { getDb } from "@/database"; + +/** + * Bootstrap project agents for a detected project directory. + * + * - Scans the project to detect its stack + * - Writes scan result to .deco/project.json + * - Creates Virtual MCP agents for each applicable template (idempotent) + */ +export async function bootstrapProjectAgents( + projectDir: string, + organizationId: string, + userId: string, +): Promise<{ scan: ProjectScanResult; agentIds: string[] }> { + // 1. Scan the project + const scan = await scanProject(projectDir); + setScanResult(scan); + + console.log( + `[project] Detected: ${scan.projectName} (${scan.framework ?? "unknown framework"}, ${scan.packageManager})`, + ); + + // 2. Write scan result to .deco/project.json for reference + try { + const decoDir = join(projectDir, ".deco"); + await Bun.write( + join(decoDir, "project.json"), + JSON.stringify(scan, null, 2), + ); + } catch { + // Non-fatal — .deco dir might not exist yet + } + + // 3. Filter applicable templates + const applicable = PROJECT_AGENT_TEMPLATES.filter( + (t) => !t.applicableWhen || t.applicableWhen(scan), + ); + + // 4. Check existing agents and create missing ones + const database = getDb(); + const agentIds: string[] = []; + + // Get existing project agents for this org + const existingAgents = await database.db + .selectFrom("connections") + .select(["id", "metadata"]) + .where("organization_id", "=", organizationId) + .where("connection_type", "=", "VIRTUAL") + .execute(); + + const existingTypes = new Set(); + for (const agent of existingAgents) { + if (agent.metadata) { + try { + const meta = + typeof agent.metadata === "string" + ? JSON.parse(agent.metadata) + : agent.metadata; + if (meta.projectAgentType) { + existingTypes.add(meta.projectAgentType); + agentIds.push(agent.id); + } + } catch { + // ignore parse errors + } + } + } + + // Import the storage class and ID generator + const { VirtualMCPStorage } = await import("@/storage/virtual"); + const storage = new VirtualMCPStorage(database.db); + + for (const template of applicable) { + if (existingTypes.has(template.id)) { + console.log(`[project] Agent "${template.id}" already exists, skipping`); + continue; + } + + const title = + typeof template.title === "function" + ? template.title(scan) + : template.title; + + const instructions = template.instructions(scan); + + const showPreview = + template.id === "project-overview" || + template.id === "project-dev-server"; + + const entity = await storage.create(organizationId, userId, { + title, + description: template.description, + icon: template.icon, + status: "active", + pinned: true, + metadata: { + instructions, + projectAgentType: template.id, + ui: showPreview + ? { + layout: { + defaultMainView: { type: "preview" }, + chatDefaultOpen: true, + }, + } + : null, + }, + connections: [], + }); + + agentIds.push(entity.id); + console.log(`[project] Created agent: ${title} (${entity.id})`); + } + + console.log( + `[project] Bootstrap complete: ${agentIds.length} agents for ${scan.projectName}`, + ); + + return { scan, agentIds }; +} diff --git a/apps/mesh/src/project/dev-server.ts b/apps/mesh/src/project/dev-server.ts new file mode 100644 index 0000000000..ff70c1f5d4 --- /dev/null +++ b/apps/mesh/src/project/dev-server.ts @@ -0,0 +1,189 @@ +/** + * Project Dev Server Management + * + * Manages the user's project dev server as a subprocess. + * Provides start/stop/restart controls and status reporting. + */ + +import type { Subprocess } from "bun"; +import type { ProjectScanResult } from "./scanner"; +import { findAvailablePort } from "@/cli/find-available-port"; + +export interface DevServerState { + status: "stopped" | "starting" | "running" | "error"; + port: number | null; + url: string | null; + pid: number | null; + error: string | null; + logs: string[]; +} + +const MAX_LOG_LINES = 200; + +let _state: DevServerState = { + status: "stopped", + port: null, + url: null, + pid: null, + error: null, + logs: [], +}; + +let _process: Subprocess | null = null; +let _scan: ProjectScanResult | null = null; + +function addLog(line: string) { + _state.logs.push(line); + if (_state.logs.length > MAX_LOG_LINES) { + _state.logs = _state.logs.slice(-MAX_LOG_LINES); + } +} + +// Strip ANSI escape codes +function stripAnsi(str: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping ANSI codes requires matching control chars + // oxlint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function pipeOutput(stream: ReadableStream) { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + + (async () => { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n"); + buffer = lines.pop() ?? ""; + for (const raw of lines) { + const stripped = stripAnsi(raw).trim(); + if (stripped) addLog(stripped); + } + } + if (buffer.trim()) { + addLog(stripAnsi(buffer).trim()); + } + })(); +} + +export function getDevServerState(): DevServerState { + return { ..._state }; +} + +export async function startProjectDevServer( + scan: ProjectScanResult, +): Promise { + if (_state.status === "running" || _state.status === "starting") { + return; + } + + _scan = scan; + _state = { + status: "starting", + port: null, + url: null, + pid: null, + error: null, + logs: [], + }; + + try { + const port = await findAvailablePort(scan.devPort); + _state.port = port; + + // Parse the dev command into parts + const parts = scan.devCommand.split(/\s+/); + const cmd = parts[0]!; + const args = parts.slice(1); + + console.log( + `[project] Starting dev server: ${scan.devCommand} (port ${port})`, + ); + + const child = Bun.spawn([cmd, ...args], { + cwd: scan.projectDir, + env: { + ...process.env, + PORT: String(port), + // Some frameworks use different env vars for port + DEV_PORT: String(port), + }, + stdio: ["inherit", "pipe", "pipe"], + }); + + _process = child; + _state.pid = child.pid; + _state.url = `http://localhost:${port}`; + + // Pipe output + if (child.stdout) pipeOutput(child.stdout as ReadableStream); + if (child.stderr) pipeOutput(child.stderr as ReadableStream); + + // Monitor process exit + child.exited.then((code) => { + if (_state.status === "running" || _state.status === "starting") { + if (code === 0 || code === null) { + _state.status = "stopped"; + } else { + _state.status = "error"; + _state.error = `Dev server exited with code ${code}`; + } + } + _process = null; + _state.pid = null; + }); + + // Wait a moment for the server to start, then mark as running + // (We can't know exactly when it's ready without health checking, + // so we use a reasonable delay) + setTimeout(() => { + if (_state.status === "starting") { + _state.status = "running"; + console.log(`[project] Dev server running at ${_state.url}`); + } + }, 3000); + } catch (error) { + _state.status = "error"; + _state.error = + error instanceof Error ? error.message : "Failed to start dev server"; + console.error("[project] Failed to start dev server:", error); + } +} + +export async function stopProjectDevServer(): Promise { + if (_process) { + _process.kill("SIGTERM"); + // Give it a moment to clean up + await Promise.race([ + _process.exited, + new Promise((r) => setTimeout(r, 5000)), + ]); + if (_process) { + _process.kill("SIGKILL"); + } + } + _state.status = "stopped"; + _state.pid = null; + _process = null; +} + +export async function restartProjectDevServer(): Promise { + await stopProjectDevServer(); + if (_scan) { + await startProjectDevServer(_scan); + } +} + +// Register cleanup handlers +function cleanup() { + if (_process) { + _process.kill("SIGTERM"); + _process = null; + } +} + +process.on("SIGINT", cleanup); +process.on("SIGTERM", cleanup); diff --git a/apps/mesh/src/project/scanner.ts b/apps/mesh/src/project/scanner.ts new file mode 100644 index 0000000000..6e76b22398 --- /dev/null +++ b/apps/mesh/src/project/scanner.ts @@ -0,0 +1,295 @@ +/** + * Project Scanner + * + * Detects the tech stack of a project directory by reading config files. + * Pure function — no side effects, no subprocesses. + */ + +import { join, basename } from "path"; + +export type FrameworkId = + | "nextjs" + | "fresh" + | "astro" + | "vite" + | "remix" + | "nuxt" + | "bun"; + +export type PackageManager = "bun" | "npm" | "yarn" | "pnpm" | "deno"; + +export type DeployTarget = "vercel" | "netlify" | "deno-deploy" | "cloudflare"; + +export interface ProjectScanResult { + projectDir: string; + projectName: string; + framework: FrameworkId | null; + packageManager: PackageManager; + devCommand: string; + devPort: number; + buildCommand: string | null; + deployTarget: DeployTarget | null; + configFiles: string[]; + hasGit: boolean; +} + +async function fileExists(path: string): Promise { + try { + return await Bun.file(path).exists(); + } catch { + return false; + } +} + +async function readJson(path: string): Promise | null> { + try { + if (!(await fileExists(path))) return null; + return JSON.parse(await Bun.file(path).text()); + } catch { + return null; + } +} + +async function readText(path: string): Promise { + try { + if (!(await fileExists(path))) return null; + return await Bun.file(path).text(); + } catch { + return null; + } +} + +const FRAMEWORK_DEFAULTS: Record< + FrameworkId, + { devPort: number; devCommand: string; buildCommand: string } +> = { + nextjs: { devPort: 3000, devCommand: "next dev", buildCommand: "next build" }, + fresh: { + devPort: 8000, + devCommand: "deno task dev", + buildCommand: "deno task build", + }, + astro: { + devPort: 4321, + devCommand: "astro dev", + buildCommand: "astro build", + }, + vite: { + devPort: 5173, + devCommand: "vite dev", + buildCommand: "vite build", + }, + remix: { + devPort: 5173, + devCommand: "remix vite:dev", + buildCommand: "remix vite:build", + }, + nuxt: { devPort: 3000, devCommand: "nuxt dev", buildCommand: "nuxt build" }, + bun: { devPort: 3000, devCommand: "bun dev", buildCommand: "bun run build" }, +}; + +async function detectPackageManager(dir: string): Promise { + if (await fileExists(join(dir, "bun.lock"))) return "bun"; + if (await fileExists(join(dir, "bun.lockb"))) return "bun"; + if (await fileExists(join(dir, "pnpm-lock.yaml"))) return "pnpm"; + if (await fileExists(join(dir, "yarn.lock"))) return "yarn"; + return "npm"; +} + +async function detectDeployTarget(dir: string): Promise { + if (await fileExists(join(dir, "vercel.json"))) return "vercel"; + if (await fileExists(join(dir, "netlify.toml"))) return "netlify"; + if (await fileExists(join(dir, "wrangler.toml"))) return "cloudflare"; + if (await fileExists(join(dir, "wrangler.jsonc"))) return "cloudflare"; + + // Check deno.json for deploy config + const denoJson = await readJson(join(dir, "deno.json")); + if (denoJson?.deploy) return "deno-deploy"; + + return null; +} + +async function detectFramework( + dir: string, + configFiles: string[], +): Promise<{ framework: FrameworkId | null; packageManager: PackageManager }> { + // 1. Deno / Fresh + const denoJsonPath = (await fileExists(join(dir, "deno.json"))) + ? "deno.json" + : (await fileExists(join(dir, "deno.jsonc"))) + ? "deno.jsonc" + : null; + + if (denoJsonPath) { + configFiles.push(denoJsonPath); + const content = await readText(join(dir, denoJsonPath)); + if (content && content.includes("$fresh")) { + return { framework: "fresh", packageManager: "deno" }; + } + return { framework: null, packageManager: "deno" }; + } + + // 2. Next.js + for (const ext of ["js", "ts", "mjs"]) { + const file = `next.config.${ext}`; + if (await fileExists(join(dir, file))) { + configFiles.push(file); + return { + framework: "nextjs", + packageManager: await detectPackageManager(dir), + }; + } + } + + // 3. Astro + for (const ext of ["js", "ts", "mjs"]) { + const file = `astro.config.${ext}`; + if (await fileExists(join(dir, file))) { + configFiles.push(file); + return { + framework: "astro", + packageManager: await detectPackageManager(dir), + }; + } + } + + // 4. Nuxt + for (const ext of ["js", "ts"]) { + const file = `nuxt.config.${ext}`; + if (await fileExists(join(dir, file))) { + configFiles.push(file); + return { + framework: "nuxt", + packageManager: await detectPackageManager(dir), + }; + } + } + + // 5. Remix — config file or deps + for (const ext of ["js", "ts"]) { + const file = `remix.config.${ext}`; + if (await fileExists(join(dir, file))) { + configFiles.push(file); + return { + framework: "remix", + packageManager: await detectPackageManager(dir), + }; + } + } + const pkg = await readJson(join(dir, "package.json")); + if (pkg) { + const deps = { + ...(pkg.dependencies as Record | undefined), + ...(pkg.devDependencies as Record | undefined), + }; + if (deps["@remix-run/react"] || deps["@remix-run/node"]) { + return { + framework: "remix", + packageManager: await detectPackageManager(dir), + }; + } + } + + // 6. Vite (generic) + for (const ext of ["js", "ts", "mjs"]) { + const file = `vite.config.${ext}`; + if (await fileExists(join(dir, file))) { + configFiles.push(file); + return { + framework: "vite", + packageManager: await detectPackageManager(dir), + }; + } + } + + // 7. Fallback — check package.json for a dev script + if (pkg || (await fileExists(join(dir, "package.json")))) { + const pm = await detectPackageManager(dir); + return { framework: pm === "bun" ? "bun" : null, packageManager: pm }; + } + + return { framework: null, packageManager: await detectPackageManager(dir) }; +} + +function resolveDevCommand( + framework: FrameworkId | null, + packageManager: PackageManager, + pkgScripts: Record | undefined, +): { devCommand: string; devPort: number; buildCommand: string | null } { + // If the project has explicit dev/build scripts in package.json, prefer those + if (pkgScripts?.dev) { + const defaults = framework ? FRAMEWORK_DEFAULTS[framework] : null; + const runner = + packageManager === "deno" + ? "deno task" + : packageManager === "bun" + ? "bun run" + : packageManager === "pnpm" + ? "pnpm" + : packageManager === "yarn" + ? "yarn" + : "npm run"; + + return { + devCommand: `${runner} dev`, + devPort: defaults?.devPort ?? 3000, + buildCommand: pkgScripts.build ? `${runner} build` : null, + }; + } + + if (framework && FRAMEWORK_DEFAULTS[framework]) { + const d = FRAMEWORK_DEFAULTS[framework]; + return { + devCommand: d.devCommand, + devPort: d.devPort, + buildCommand: d.buildCommand, + }; + } + + return { + devCommand: + packageManager === "deno" ? "deno task dev" : `${packageManager} run dev`, + devPort: 3000, + buildCommand: null, + }; +} + +export async function scanProject( + projectDir: string, +): Promise { + const configFiles: string[] = []; + + // Check for package.json + const pkg = await readJson(join(projectDir, "package.json")); + if (pkg) configFiles.push("package.json"); + + const { framework, packageManager } = await detectFramework( + projectDir, + configFiles, + ); + + const pkgScripts = pkg?.scripts as Record | undefined; + const { devCommand, devPort, buildCommand } = resolveDevCommand( + framework, + packageManager, + pkgScripts, + ); + + const deployTarget = await detectDeployTarget(projectDir); + // .git can be a directory or a file (worktrees), use existsSync for both + const { existsSync } = await import("fs"); + const hasGit = existsSync(join(projectDir, ".git")); + + return { + projectDir, + projectName: basename(projectDir), + framework, + packageManager, + devCommand, + devPort, + buildCommand, + deployTarget, + configFiles, + hasGit, + }; +} diff --git a/apps/mesh/src/project/state.ts b/apps/mesh/src/project/state.ts new file mode 100644 index 0000000000..4da11d7c0a --- /dev/null +++ b/apps/mesh/src/project/state.ts @@ -0,0 +1,18 @@ +/** + * Project State + * + * Module-level holder for the project scan result. + * Set once during bootstrap, read by API routes. + */ + +import type { ProjectScanResult } from "./scanner"; + +let _scanResult: ProjectScanResult | null = null; + +export function setScanResult(result: ProjectScanResult): void { + _scanResult = result; +} + +export function getScanResult(): ProjectScanResult | null { + return _scanResult; +} diff --git a/apps/mesh/src/settings/resolve-config.ts b/apps/mesh/src/settings/resolve-config.ts index 5388d11eab..94d8e52c27 100644 --- a/apps/mesh/src/settings/resolve-config.ts +++ b/apps/mesh/src/settings/resolve-config.ts @@ -101,6 +101,9 @@ export function resolveConfig( envVars.S3_FORCE_PATH_STYLE === "true" || envVars.S3_FORCE_PATH_STYLE === "1", + // Project + projectDir: flags.projectDir ?? envVars.DECOCMS_PROJECT_DIR ?? null, + // Runtime flags isCli: true, noTui: flags.noTui === true, diff --git a/apps/mesh/src/settings/types.ts b/apps/mesh/src/settings/types.ts index d662782688..d2faaa5b8c 100644 --- a/apps/mesh/src/settings/types.ts +++ b/apps/mesh/src/settings/types.ts @@ -53,6 +53,10 @@ export interface Settings { s3SecretAccessKey: string | undefined; s3ForcePathStyle: boolean; + // Project + /** Absolute path to the user's project directory (CWD where CLI was invoked). Null when not running against a project. */ + projectDir: string | null; + // Runtime flags (set by CLI) isCli: boolean; noTui: boolean; @@ -73,6 +77,7 @@ export interface CliFlags { noTui?: boolean; vitePort?: string; nodeEnv?: "production" | "development" | "test"; + projectDir?: string; } export interface ServiceInputs { diff --git a/apps/mesh/src/web/components/chat/no-ai-provider-empty-state.tsx b/apps/mesh/src/web/components/chat/no-ai-provider-empty-state.tsx index df74706e4a..0885a88b22 100644 --- a/apps/mesh/src/web/components/chat/no-ai-provider-empty-state.tsx +++ b/apps/mesh/src/web/components/chat/no-ai-provider-empty-state.tsx @@ -1,7 +1,8 @@ import { Suspense } from "react"; -import { CpuChip01 } from "@untitledui/icons"; +import { Zap } from "@untitledui/icons"; import { Skeleton } from "@deco/ui/components/skeleton.tsx"; import { ProviderCardGrid } from "@/web/views/settings/org-ai-providers"; +import { usePublicConfig } from "@/web/hooks/use-public-config"; interface NoAiProviderEmptyStateProps { title?: string; @@ -9,18 +10,35 @@ interface NoAiProviderEmptyStateProps { } export function NoAiProviderEmptyState({ - title = "Connect an AI provider", - description = "Keys are stored encrypted in the vault.", + title, + description, }: NoAiProviderEmptyStateProps = {}) { + const config = usePublicConfig(); + const projectName = config.projectName; + + const heading = + title ?? + (projectName + ? `${projectName} is ready for agents` + : "Your agents are almost ready"); + + const subtitle = + description ?? + (projectName + ? "Choose how to power your AI team." + : "Connect an AI provider to get started."); + return (
-
- +
+
-
-

{title}

-

{description}

+
+

+ {heading} +

+

{subtitle}

- +
); } diff --git a/apps/mesh/src/web/components/chat/side-panel-chat.tsx b/apps/mesh/src/web/components/chat/side-panel-chat.tsx index 7450da8822..92788b907c 100644 --- a/apps/mesh/src/web/components/chat/side-panel-chat.tsx +++ b/apps/mesh/src/web/components/chat/side-panel-chat.tsx @@ -204,18 +204,11 @@ function ChatPanelContent({ variant }: { variant?: "home" | "default" }) { const [activePanel, setActivePanel] = useState<"chat" | "context">("chat"); if (allKeys.length === 0) { - const title = "No model provider connected"; - const description = - "Connect to a model provider to unlock AI-powered features."; - return ( - + diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx index f1cafd05a9..2d3bd1b38d 100644 --- a/apps/mesh/src/web/components/home/agents-list.tsx +++ b/apps/mesh/src/web/components/home/agents-list.tsx @@ -15,6 +15,7 @@ import { useVirtualMCPs, } from "@decocms/mesh-sdk"; import type { ProjectLocator } from "@decocms/mesh-sdk"; +import { usePublicConfig } from "@/web/hooks/use-public-config"; function readRecentAgentIds(locator: ProjectLocator): string[] { try { @@ -152,12 +153,18 @@ function CreateAgentButton() { ); } +function isProjectAgent(agent: { metadata?: Record | null }) { + return !!(agent.metadata as Record | null)?.projectAgentType; +} + function AgentsListContent() { const virtualMcps = useVirtualMCPs(); const { locator } = useProjectContext(); const [siteEditorModalOpen, setSiteEditorModalOpen] = useState(false); const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); const navigateToAgent = useNavigateToAgent(); + const config = usePublicConfig(); + const isProjectMode = !!config.projectDir; const siteEditorAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "site-editor", @@ -168,12 +175,17 @@ function AgentsListContent() { const recentIds = readRecentAgentIds(locator); - // Filter out Decopilot, sort by most recently used (from localStorage), then take top 5 - const agents = virtualMcps - .filter( - (agent): agent is typeof agent & { id: string } => - agent.id !== null && !isDecopilot(agent.id), - ) + // Separate project agents from regular agents + const allAgents = virtualMcps.filter( + (agent): agent is typeof agent & { id: string } => + agent.id !== null && !isDecopilot(agent.id), + ); + + const projectAgents = allAgents.filter(isProjectAgent); + const regularAgents = allAgents.filter((a) => !isProjectAgent(a)); + + // Sort regular agents by most recently used, then take top 5 + const sortedRegular = regularAgents .sort((a, b) => { const aIdx = recentIds.indexOf(a.id); const bIdx = recentIds.indexOf(b.id); @@ -195,28 +207,44 @@ function AgentsListContent() { a.title === siteDiagnosticsAgent.title), ); - const hasAgents = agents.length > 0; + const hasAgents = sortedRegular.length > 0 || projectAgents.length > 0; return ( <>
- setSiteEditorModalOpen(true)} - /> - navigateToAgent(existingDiagnostics.id) - : () => setDiagnosticsModalOpen(true) - } - /> - {agents - .filter((a) => a.id !== existingDiagnostics?.id) + {/* Show project agents first when in project mode */} + {isProjectMode && + projectAgents.map((agent) => ( + navigateToAgent(agent.id)} + /> + ))} + {/* Well-known agents (only show when not in project mode) */} + {!isProjectMode && ( + <> + setSiteEditorModalOpen(true)} + /> + navigateToAgent(existingDiagnostics.id) + : () => setDiagnosticsModalOpen(true) + } + /> + + )} + {sortedRegular + .filter( + (a) => a.id !== existingDiagnostics?.id && !isProjectAgent(a), + ) .map((agent) => ( void; +}) { + return ( +
+ +
+ + {url} +
+ +
+ ); +} + +function PreviewLoading({ status }: { status: string }) { + return ( +
+ {status === "starting" ? ( + <> + +

Starting dev server...

+ + ) : status === "error" ? ( + <> +

Dev server error

+

Check the logs for details

+ + ) : ( + <> +

Dev server is not running

+

Start it to see the preview

+ + )} +
+ ); +} + +export function PreviewPanel() { + const { data: devServer } = useDevServerState(); + const iframeRef = useRef(null); + const [iframeKey, setIframeKey] = useState(0); + + const isRunning = devServer?.status === "running"; + const url = devServer?.url; + + const handleRefresh = () => { + setIframeKey((k) => k + 1); + }; + + if (!isRunning || !url) { + return ( +
+ +
+ ); + } + + return ( +
+ +