Skip to content

Commit 4c4b348

Browse files
vibeguiclaude
andcommitted
feat(project): local dev agents — auto-detect project, create agent team, start dev server
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) <noreply@anthropic.com>
1 parent 095e6ab commit 4c4b348

28 files changed

Lines changed: 1799 additions & 48 deletions

apps/mesh/src/api/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import proxyRoutes from "./routes/proxy";
4747
import { createKVRoutes } from "./routes/kv";
4848
import { createTriggerCallbackRoutes } from "./routes/trigger-callback";
4949
import publicConfigRoutes from "./routes/public-config";
50+
import projectRoutes from "./routes/project";
5051
import filesRoutes from "./routes/files";
5152
import selfRoutes from "./routes/self";
5253
import { shouldSkipMeshContext, SYSTEM_PATHS } from "./utils/paths";
@@ -566,6 +567,7 @@ export async function createApp(options: CreateAppOptions = {}) {
566567
// Public Configuration (no auth required)
567568
// ============================================================================
568569
app.route("/api/config", publicConfigRoutes);
570+
app.route("/api/project", projectRoutes);
569571

570572
// ============================================================================
571573
// Better Auth Routes
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/**
2+
* Project API Routes
3+
*
4+
* Exposes project scan results and dev server control endpoints.
5+
* Only available in local mode when a project directory is detected.
6+
*/
7+
8+
import { Hono } from "hono";
9+
import { getScanResult } from "@/project/state";
10+
import {
11+
getDevServerState,
12+
startProjectDevServer,
13+
stopProjectDevServer,
14+
restartProjectDevServer,
15+
} from "@/project/dev-server";
16+
17+
const app = new Hono();
18+
19+
/**
20+
* GET /api/project
21+
* Returns project scan result and dev server state
22+
*/
23+
app.get("/", (c) => {
24+
const scan = getScanResult();
25+
if (!scan) {
26+
return c.json({ success: false, error: "No project detected" }, 404);
27+
}
28+
29+
return c.json({
30+
success: true,
31+
scan,
32+
devServer: getDevServerState(),
33+
});
34+
});
35+
36+
/**
37+
* GET /api/project/dev-server
38+
* Returns just the dev server state (for lightweight polling)
39+
*/
40+
app.get("/dev-server", (c) => {
41+
return c.json({
42+
success: true,
43+
devServer: getDevServerState(),
44+
});
45+
});
46+
47+
/**
48+
* POST /api/project/dev-server/start
49+
* Starts the project dev server
50+
*/
51+
app.post("/dev-server/start", async (c) => {
52+
const scan = getScanResult();
53+
if (!scan) {
54+
return c.json({ success: false, error: "No project detected" }, 404);
55+
}
56+
57+
await startProjectDevServer(scan);
58+
return c.json({ success: true, devServer: getDevServerState() });
59+
});
60+
61+
/**
62+
* POST /api/project/dev-server/stop
63+
* Stops the project dev server
64+
*/
65+
app.post("/dev-server/stop", async (c) => {
66+
await stopProjectDevServer();
67+
return c.json({ success: true, devServer: getDevServerState() });
68+
});
69+
70+
/**
71+
* POST /api/project/dev-server/restart
72+
* Restarts the project dev server
73+
*/
74+
app.post("/dev-server/restart", async (c) => {
75+
await restartProjectDevServer();
76+
return c.json({ success: true, devServer: getDevServerState() });
77+
});
78+
79+
/**
80+
* GET /api/project/dev-server/logs
81+
* Returns dev server logs
82+
*/
83+
app.get("/dev-server/logs", (c) => {
84+
const state = getDevServerState();
85+
return c.json({ success: true, logs: state.logs });
86+
});
87+
88+
/**
89+
* Reverse proxy to the project dev server, stripping frame-blocking headers.
90+
* This allows the preview iframe to load the dev server content.
91+
* Handles both /api/project/preview and /api/project/preview/* paths.
92+
*/
93+
app.all("/preview", (c) => handlePreviewProxy(c));
94+
app.all("/preview/*", (c) => handlePreviewProxy(c));
95+
96+
async function handlePreviewProxy(c: {
97+
req: { path: string; url: string; method: string; raw: Request };
98+
text: (body: string, status: number) => Response;
99+
}) {
100+
const devServer = getDevServerState();
101+
if (!devServer.url) {
102+
return c.text("Dev server not running", 503);
103+
}
104+
105+
// Reconstruct the target URL from the wildcard path
106+
const path = c.req.path.replace(/^\/api\/project\/preview\/?/, "/");
107+
const targetUrl = new URL(path || "/", devServer.url);
108+
109+
// Forward query string
110+
const reqUrl = new URL(c.req.url);
111+
targetUrl.search = reqUrl.search;
112+
113+
try {
114+
const headers = new Headers(c.req.raw.headers);
115+
// Remove host header to avoid conflicts
116+
headers.delete("host");
117+
118+
const response = await fetch(targetUrl.toString(), {
119+
method: c.req.method,
120+
headers,
121+
body:
122+
c.req.method !== "GET" && c.req.method !== "HEAD"
123+
? c.req.raw.body
124+
: undefined,
125+
redirect: "manual",
126+
});
127+
128+
// Clone response and strip frame-blocking headers
129+
const responseHeaders = new Headers(response.headers);
130+
responseHeaders.delete("x-frame-options");
131+
responseHeaders.delete("content-security-policy");
132+
133+
return new Response(response.body, {
134+
status: response.status,
135+
statusText: response.statusText,
136+
headers: responseHeaders,
137+
});
138+
} catch {
139+
return c.text("Failed to proxy to dev server", 502);
140+
}
141+
}
142+
143+
export default app;

apps/mesh/src/api/routes/public-config.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import { Hono } from "hono";
9+
import { basename } from "path";
910
import { getConfig, getThemeConfig, type ThemeConfig } from "@/core/config";
1011
import { isLocalMode } from "@/auth/local-mode";
1112
import { getInternalUrl } from "@/core/server-constants";
@@ -43,6 +44,15 @@ export type PublicConfig = {
4344
* Requires FIRECRAWL_API_KEY to be configured.
4445
*/
4546
brandExtractEnabled?: boolean;
47+
/**
48+
* Absolute path to the user's project directory.
49+
* Only set in local mode when running inside a project.
50+
*/
51+
projectDir?: string;
52+
/**
53+
* Human-readable project name (basename of projectDir).
54+
*/
55+
projectName?: string;
4656
};
4757

4858
/**
@@ -61,6 +71,11 @@ app.get("/", (c) => {
6171
...(isLocalMode() && { internalUrl: getInternalUrl() }),
6272
...(getSettings().enableDecoImport && { enableDecoImport: true }),
6373
brandExtractEnabled: !!getSettings().firecrawlApiKey,
74+
...(isLocalMode() &&
75+
getSettings().projectDir != null && {
76+
projectDir: getSettings().projectDir as string,
77+
projectName: basename(getSettings().projectDir as string),
78+
}),
6479
};
6580

6681
return c.json({ success: true, config });

apps/mesh/src/cli.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,18 @@
1515
import { parseArgs } from "util";
1616
import { homedir } from "os";
1717
import { join } from "path";
18+
import { existsSync } from "fs";
19+
20+
/** Check if a directory contains a project marker (package.json, deno.json, etc.) */
21+
function detectProjectDir(dir: string): string | undefined {
22+
const markers = ["package.json", "deno.json", "deno.jsonc"];
23+
for (const marker of markers) {
24+
if (existsSync(join(dir, marker))) {
25+
return dir;
26+
}
27+
}
28+
return undefined;
29+
}
1830

1931
const { values, positionals } = parseArgs({
2032
args: process.argv.slice(2),
@@ -60,6 +72,9 @@ const { values, positionals } = parseArgs({
6072
type: "string",
6173
default: "1",
6274
},
75+
project: {
76+
type: "string",
77+
},
6378
vibe: {
6479
type: "boolean",
6580
default: false,
@@ -94,6 +109,7 @@ Server Options:
94109
Dev Options:
95110
--vite-port <port> Vite dev server port (default: 4000)
96111
--base-url <url> Base URL for the server
112+
--project <path> Project directory to manage (default: auto-detect CWD)
97113
98114
Environment Variables:
99115
PORT Port to listen on (default: 3000)
@@ -203,6 +219,7 @@ if (command === "dev") {
203219
skipMigrations: values["skip-migrations"] === true,
204220
noTui,
205221
localMode: values["no-local-mode"] !== true,
222+
projectDir: values.project || detectProjectDir(process.cwd()),
206223
};
207224

208225
if (noTui) {
@@ -270,6 +287,7 @@ const serveOptions = {
270287
const n = Number(values["num-threads"]);
271288
return Number.isInteger(n) && n > 0 ? n : 1;
272289
})(),
290+
projectDir: values.project || detectProjectDir(process.cwd()),
273291
};
274292

275293
const noTui = values["no-tui"] === true || !process.stdout.isTTY;

apps/mesh/src/cli/commands/dev.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface DevOptions {
2525
skipMigrations: boolean;
2626
noTui?: boolean;
2727
localMode: boolean;
28+
projectDir?: string;
2829
}
2930

3031
// Strip ANSI escape codes from a string
@@ -102,6 +103,7 @@ export async function startDevServer(
102103
skipMigrations: options.skipMigrations,
103104
noTui: options.noTui,
104105
vitePort: options.vitePort,
106+
projectDir: options.projectDir,
105107
});
106108

107109
for (const s of services) {
@@ -130,6 +132,9 @@ export async function startDevServer(
130132
DECOCMS_HOME: settings.dataDir,
131133
DATA_DIR: settings.dataDir,
132134
DECO_CLI: "1",
135+
...(settings.projectDir
136+
? { DECOCMS_PROJECT_DIR: settings.projectDir }
137+
: {}),
133138
...(settings.baseUrl ? { BASE_URL: settings.baseUrl } : {}),
134139
},
135140
stdio: [

apps/mesh/src/cli/commands/serve.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface ServeOptions {
2525
localMode: boolean;
2626
noTui?: boolean;
2727
numThreads?: number;
28+
projectDir?: string;
2829
}
2930

3031
// Strip ANSI escape codes from a string
@@ -143,6 +144,7 @@ export async function startServer(options: ServeOptions): Promise<void> {
143144
skipMigrations: options.skipMigrations,
144145
noTui: options.noTui,
145146
nodeEnv: "production",
147+
projectDir: options.projectDir,
146148
});
147149

148150
for (const s of services) {

apps/mesh/src/index.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,43 @@ if (settings.localMode && !isWorker) {
125125
try {
126126
const seeded = await seedLocalMode();
127127
void seeded;
128+
129+
// Bootstrap project agents if running in a project directory
130+
if (settings.projectDir) {
131+
try {
132+
const { getDb } = await import("./database");
133+
const { getLocalAdminUser } = await import("./auth/local-mode");
134+
const database = getDb();
135+
const user = await getLocalAdminUser();
136+
if (user) {
137+
// Get the user's organization
138+
const membership = await database.db
139+
.selectFrom("member")
140+
.select("organizationId")
141+
.where("userId", "=", user.id)
142+
.executeTakeFirst();
143+
144+
if (membership) {
145+
const { bootstrapProjectAgents } = await import(
146+
"./project/bootstrap"
147+
);
148+
const { scan } = await bootstrapProjectAgents(
149+
settings.projectDir,
150+
membership.organizationId,
151+
user.id,
152+
);
153+
154+
// Auto-start the project dev server
155+
const { startProjectDevServer } = await import(
156+
"./project/dev-server"
157+
);
158+
await startProjectDevServer(scan);
159+
}
160+
}
161+
} catch (error) {
162+
console.error("[project] Failed to bootstrap project:", error);
163+
}
164+
}
128165
} catch (error) {
129166
console.error("Failed to seed local mode:", error);
130167
} finally {

0 commit comments

Comments
 (0)