Initial commit: Product OS platform

- Control Plane API with Gemini integration
- Executors: Deploy, Analytics, Marketing
- MCP Adapter for Continue integration
- VSCode/VSCodium extension
- Tool registry and run tracking
- In-memory storage for local dev
- Terraform infrastructure setup
This commit is contained in:
2026-01-19 20:34:43 -08:00
commit b6d7148ded
58 changed files with 5365 additions and 0 deletions

61
.continue/config.yaml Normal file
View File

@@ -0,0 +1,61 @@
# Continue Configuration for Product OS
# https://docs.continue.dev/reference/config
name: Product OS
# Models - using Gemini via your Control Plane
models:
- name: Gemini (Product OS)
provider: openai # Continue uses OpenAI-compatible API format
model: gemini-1.5-flash
apiBase: http://localhost:8080 # Your Control Plane
apiKey: not-needed # Auth handled by Control Plane
# Default model for chat
model: Gemini (Product OS)
# MCP Servers - your Product OS tools
experimental:
modelContextProtocolServers:
- name: productos
command: npx
args:
- tsx
- /Users/markhenderson/Cursor Projects/Master Biz AI/platform/backend/mcp-adapter/src/index.ts
env:
CONTROL_PLANE_URL: http://localhost:8080
TENANT_ID: t_continue
# Context providers
contextProviders:
- name: code
params:
nFinal: 5
nRetrieve: 10
- name: docs
- name: terminal
- name: problems
# Slash commands
slashCommands:
- name: deploy
description: Deploy a service to Cloud Run
- name: analytics
description: Get funnel analytics
- name: marketing
description: Generate marketing content
# Custom instructions for the AI
systemMessage: |
You are Product OS, an AI assistant for building and operating SaaS products on Google Cloud.
You have access to these tools via MCP:
- deploy_service: Deploy Cloud Run services
- get_service_status: Check deployment health
- get_funnel_analytics: Analyze conversion funnels
- get_top_drivers: Understand what drives metrics
- generate_marketing_posts: Create social media content
- chat_with_gemini: General AI conversation
When users ask to deploy, analyze, or generate content, use the appropriate tool.
Always confirm before deploying to production.

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependencies
node_modules/
package-lock.json
# Build outputs
dist/
*.vsix
# Environment
.env
.env.local
.env.*.local
# IDE
.idea/
*.swp
*.swo
.DS_Store
# Logs
*.log
npm-debug.log*
# Testing
coverage/
# Temporary
tmp/
temp/
# Continue local config (may contain API keys)
.continue/config.json
# GCP credentials (never commit these!)
*.json
!package.json
!tsconfig.json
!**/package.json
!**/tsconfig.json

View File

View File

0
architecture.md Normal file
View File

View File

@@ -0,0 +1,30 @@
{
"name": "@productos/control-plane",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"lint": "eslint ."
},
"dependencies": {
"@google-cloud/firestore": "^7.11.0",
"@google-cloud/storage": "^7.14.0",
"@fastify/cors": "^10.0.0",
"@fastify/helmet": "^13.0.0",
"@fastify/rate-limit": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0",
"zod": "^3.23.8",
"nanoid": "^5.0.7"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"eslint": "^9.8.0"
}
}

View File

@@ -0,0 +1,11 @@
import { FastifyRequest } from "fastify";
import { config } from "./config.js";
/**
* V1: dev mode = trust caller.
* V2: validate Google OAuth/IAP identity token.
*/
export async function requireAuth(req: FastifyRequest) {
if (config.authMode === "dev") return;
throw new Error("AUTH_MODE oauth not yet implemented");
}

View File

@@ -0,0 +1,10 @@
export const config = {
port: Number(process.env.PORT ?? 8080),
projectId: process.env.GCP_PROJECT_ID ?? "productos-local",
artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "productos-artifacts-local",
runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs",
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
authMode: process.env.AUTH_MODE ?? "dev",
// Use in-memory storage when STORAGE_MODE=memory or when no GCP project is configured
storageMode: process.env.STORAGE_MODE ?? (process.env.GCP_PROJECT_ID ? "gcp" : "memory")
};

View File

@@ -0,0 +1,365 @@
/**
* Gemini Integration for Product OS
*
* Supports:
* - Chat completions with streaming
* - Tool/function calling
* - Context-aware responses
*
* Set GOOGLE_CLOUD_PROJECT and optionally GEMINI_MODEL env vars.
* For local dev without Vertex AI, set GEMINI_API_KEY for AI Studio.
*/
import { config } from "./config.js";
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ToolCall {
name: string;
arguments: Record<string, any>;
}
export interface ChatResponse {
message: string;
toolCalls?: ToolCall[];
finishReason: "stop" | "tool_calls" | "error";
}
// Tool definitions that Gemini can call
export const PRODUCT_OS_TOOLS = [
{
name: "deploy_service",
description: "Deploy a Cloud Run service. Use when user wants to deploy, ship, or launch code.",
parameters: {
type: "object",
properties: {
service_name: { type: "string", description: "Name of the service to deploy" },
repo: { type: "string", description: "Git repository URL" },
ref: { type: "string", description: "Git branch, tag, or commit" },
env: { type: "string", enum: ["dev", "staging", "prod"], description: "Target environment" }
},
required: ["service_name"]
}
},
{
name: "get_funnel_analytics",
description: "Get funnel conversion metrics. Use when user asks about funnels, conversions, or drop-offs.",
parameters: {
type: "object",
properties: {
range_days: { type: "integer", description: "Number of days to analyze", default: 30 }
}
}
},
{
name: "get_top_drivers",
description: "Identify top factors driving a metric. Use when user asks why something changed or what drives conversions.",
parameters: {
type: "object",
properties: {
metric: { type: "string", description: "The metric to analyze (e.g., 'conversion', 'retention')" },
range_days: { type: "integer", description: "Number of days to analyze", default: 30 }
},
required: ["metric"]
}
},
{
name: "generate_marketing_posts",
description: "Generate social media posts for a campaign. Use when user wants to create marketing content.",
parameters: {
type: "object",
properties: {
goal: { type: "string", description: "Campaign goal (e.g., 'launch announcement')" },
product: { type: "string", description: "Product or feature name" },
channels: {
type: "array",
items: { type: "string" },
description: "Social channels (e.g., ['x', 'linkedin'])"
}
},
required: ["goal"]
}
},
{
name: "get_service_status",
description: "Check the status of a deployed service. Use when user asks about service health or deployment status.",
parameters: {
type: "object",
properties: {
service_name: { type: "string", description: "Name of the service" },
region: { type: "string", description: "GCP region", default: "us-central1" }
},
required: ["service_name"]
}
},
{
name: "generate_code",
description: "Generate or modify code. Use when user asks to write, fix, refactor, or change code.",
parameters: {
type: "object",
properties: {
task: { type: "string", description: "What code change to make" },
file_path: { type: "string", description: "Target file path (if known)" },
language: { type: "string", description: "Programming language" },
context: { type: "string", description: "Additional context about the codebase" }
},
required: ["task"]
}
}
];
// System prompt for Product OS assistant
const SYSTEM_PROMPT = `You are Product OS, an AI assistant specialized in helping users launch and operate SaaS products on Google Cloud.
You can help with:
- Deploying services to Cloud Run
- Analyzing product metrics and funnels
- Generating marketing content
- Writing and modifying code
- Understanding what drives user behavior
When users ask you to do something, use the available tools to take action. Be concise and helpful.
If a user asks about code, analyze their request and either:
1. Use generate_code tool for code changes
2. Provide explanations directly
Always confirm before taking destructive actions like deploying to production.`;
/**
* Chat with Gemini
* Uses Vertex AI in production, or AI Studio API key for local dev
*/
export async function chat(
messages: ChatMessage[],
options: { stream?: boolean } = {}
): Promise<ChatResponse> {
const apiKey = process.env.GEMINI_API_KEY;
const projectId = config.projectId;
const model = process.env.GEMINI_MODEL ?? "gemini-1.5-flash";
// Build the request
const contents = [
{ role: "user", parts: [{ text: SYSTEM_PROMPT }] },
{ role: "model", parts: [{ text: "Understood. I'm Product OS, ready to help you build and operate your SaaS product. How can I help?" }] },
...messages.map(m => ({
role: m.role === "assistant" ? "model" : "user",
parts: [{ text: m.content }]
}))
];
const tools = [{
functionDeclarations: PRODUCT_OS_TOOLS
}];
// Use AI Studio API if API key is set (local dev)
if (apiKey) {
return chatWithAIStudio(apiKey, model, contents, tools);
}
// Use Vertex AI if project is set (production)
if (projectId && projectId !== "productos-local") {
return chatWithVertexAI(projectId, model, contents, tools);
}
// Mock response for local dev without credentials
return mockChat(messages);
}
async function chatWithAIStudio(
apiKey: string,
model: string,
contents: any[],
tools: any[]
): Promise<ChatResponse> {
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
const response = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contents,
tools,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 2048
}
})
});
if (!response.ok) {
const error = await response.text();
console.error("Gemini API error:", error);
throw new Error(`Gemini API error: ${response.status}`);
}
const data = await response.json();
return parseGeminiResponse(data);
}
async function chatWithVertexAI(
projectId: string,
model: string,
contents: any[],
tools: any[]
): Promise<ChatResponse> {
// Vertex AI endpoint
const location = process.env.VERTEX_LOCATION ?? "us-central1";
const url = `https://${location}-aiplatform.googleapis.com/v1/projects/${projectId}/locations/${location}/publishers/google/models/${model}:generateContent`;
// Get access token (requires gcloud auth)
const { GoogleAuth } = await import("google-auth-library");
const auth = new GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] });
const client = await auth.getClient();
const token = await client.getAccessToken();
const response = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token.token}`
},
body: JSON.stringify({
contents,
tools,
generationConfig: {
temperature: 0.7,
maxOutputTokens: 2048
}
})
});
if (!response.ok) {
const error = await response.text();
console.error("Vertex AI error:", error);
throw new Error(`Vertex AI error: ${response.status}`);
}
const data = await response.json();
return parseGeminiResponse(data);
}
function parseGeminiResponse(data: any): ChatResponse {
const candidate = data.candidates?.[0];
if (!candidate) {
return { message: "No response from Gemini", finishReason: "error" };
}
const content = candidate.content;
const parts = content?.parts ?? [];
// Check for function calls
const functionCalls = parts.filter((p: any) => p.functionCall);
if (functionCalls.length > 0) {
const toolCalls = functionCalls.map((p: any) => ({
name: p.functionCall.name,
arguments: p.functionCall.args ?? {}
}));
return {
message: "",
toolCalls,
finishReason: "tool_calls"
};
}
// Regular text response
const text = parts.map((p: any) => p.text ?? "").join("");
return {
message: text,
finishReason: "stop"
};
}
/**
* Mock chat for local development without Gemini credentials
*/
function mockChat(messages: ChatMessage[]): ChatResponse {
const lastMessage = messages[messages.length - 1]?.content.toLowerCase() ?? "";
// Check marketing/campaign FIRST (before deploy) since "launch" can be ambiguous
if (lastMessage.includes("marketing") || lastMessage.includes("campaign") || lastMessage.includes("post") ||
(lastMessage.includes("launch") && !lastMessage.includes("deploy"))) {
return {
message: "",
toolCalls: [{
name: "generate_marketing_posts",
arguments: { goal: "product launch", channels: ["x", "linkedin"] }
}],
finishReason: "tool_calls"
};
}
// Simple keyword matching to simulate tool calls
if (lastMessage.includes("deploy") || lastMessage.includes("ship") || lastMessage.includes("staging") || lastMessage.includes("production")) {
return {
message: "",
toolCalls: [{
name: "deploy_service",
arguments: { service_name: "my-service", env: lastMessage.includes("staging") ? "staging" : lastMessage.includes("prod") ? "prod" : "dev" }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("funnel") || lastMessage.includes("conversion") || lastMessage.includes("analytics")) {
return {
message: "",
toolCalls: [{
name: "get_funnel_analytics",
arguments: { range_days: 30 }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("why") || lastMessage.includes("driver") || lastMessage.includes("cause")) {
return {
message: "",
toolCalls: [{
name: "get_top_drivers",
arguments: { metric: "conversion", range_days: 30 }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("status") || lastMessage.includes("health")) {
return {
message: "",
toolCalls: [{
name: "get_service_status",
arguments: { service_name: "my-service" }
}],
finishReason: "tool_calls"
};
}
if (lastMessage.includes("code") || lastMessage.includes("function") || lastMessage.includes("write") || lastMessage.includes("create")) {
return {
message: "",
toolCalls: [{
name: "generate_code",
arguments: { task: lastMessage, language: "typescript" }
}],
finishReason: "tool_calls"
};
}
// Default response
return {
message: `I'm Product OS, your AI assistant for building and operating SaaS products. I can help you:
• **Deploy** services to Cloud Run
• **Analyze** funnel metrics and conversions
• **Generate** marketing content
• **Understand** what drives user behavior
What would you like to do?
_(Note: Running in mock mode - set GEMINI_API_KEY for real AI responses)_`,
finishReason: "stop"
};
}

View File

@@ -0,0 +1,29 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import { config } from "./config.js";
import { healthRoutes } from "./routes/health.js";
import { toolRoutes } from "./routes/tools.js";
import { runRoutes } from "./routes/runs.js";
import { chatRoutes } from "./routes/chat.js";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(helmet);
await app.register(sensible);
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(healthRoutes);
await app.register(toolRoutes);
await app.register(runRoutes);
await app.register(chatRoutes);
app.listen({ port: config.port, host: "0.0.0.0" }).then(() => {
console.log(`🚀 Control Plane API running on http://localhost:${config.port}`);
}).catch((err) => {
app.log.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,10 @@
import type { ToolDef } from "./types.js";
import { listTools } from "./storage/index.js";
/**
* Simple registry. V2: cache + versioning + per-tenant overrides.
*/
export async function getRegistry(): Promise<Record<string, ToolDef>> {
const tools = await listTools();
return Object.fromEntries(tools.map(t => [t.name, t]));
}

View File

@@ -0,0 +1,306 @@
import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { chat, ChatMessage, ChatResponse, ToolCall } from "../gemini.js";
import { getRegistry } from "../registry.js";
import { saveRun, writeArtifactText } from "../storage/index.js";
import { nanoid } from "nanoid";
import type { RunRecord } from "../types.js";
interface ChatRequest {
messages: ChatMessage[];
context?: {
files?: { path: string; content: string }[];
selection?: { path: string; text: string; startLine: number };
};
autoExecuteTools?: boolean;
}
interface ChatResponseWithRuns extends ChatResponse {
runs?: RunRecord[];
}
export async function chatRoutes(app: FastifyInstance) {
/**
* Chat endpoint - proxies to Gemini with tool calling support
*/
app.post<{ Body: ChatRequest }>("/chat", async (req): Promise<ChatResponseWithRuns> => {
await requireAuth(req);
const { messages, context, autoExecuteTools = true } = req.body;
// Enhance messages with context if provided
let enhancedMessages = [...messages];
if (context?.files?.length) {
const fileContext = context.files
.map(f => `File: ${f.path}\n\`\`\`\n${f.content}\n\`\`\``)
.join("\n\n");
enhancedMessages = [
{ role: "user" as const, content: `Context:\n${fileContext}` },
...messages
];
}
if (context?.selection) {
const selectionContext = `Selected code in ${context.selection.path} (line ${context.selection.startLine}):\n\`\`\`\n${context.selection.text}\n\`\`\``;
enhancedMessages = [
{ role: "user" as const, content: selectionContext },
...messages
];
}
// Call Gemini
const response = await chat(enhancedMessages);
// If tool calls and auto-execute is enabled, run them
if (response.toolCalls && response.toolCalls.length > 0 && autoExecuteTools) {
const runs = await executeToolCalls(response.toolCalls, req.body);
// Generate a summary of what was done
const summary = generateToolSummary(response.toolCalls, runs);
return {
message: summary,
toolCalls: response.toolCalls,
runs,
finishReason: "tool_calls"
};
}
return response;
});
/**
* Streaming chat endpoint (SSE)
*/
app.get("/chat/stream", async (req, reply) => {
await requireAuth(req);
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive"
});
// For now, return a message that streaming is not yet implemented
reply.raw.write(`data: ${JSON.stringify({ message: "Streaming not yet implemented", finishReason: "stop" })}\n\n`);
reply.raw.end();
});
}
/**
* Execute tool calls by routing to the appropriate executor
*/
async function executeToolCalls(
toolCalls: ToolCall[],
request: ChatRequest
): Promise<RunRecord[]> {
const runs: RunRecord[] = [];
const registry = await getRegistry();
for (const toolCall of toolCalls) {
// Map tool call names to actual tools
const toolMapping: Record<string, string> = {
"deploy_service": "cloudrun.deploy_service",
"get_funnel_analytics": "analytics.funnel_summary",
"get_top_drivers": "analytics.top_drivers",
"generate_marketing_posts": "marketing.generate_channel_posts",
"get_service_status": "cloudrun.get_service_status",
"generate_code": "code.generate" // This one is special - handled inline
};
const actualToolName = toolMapping[toolCall.name];
// Special handling for code generation
if (toolCall.name === "generate_code") {
const codeRun = await handleCodeGeneration(toolCall.arguments);
runs.push(codeRun);
continue;
}
const tool = actualToolName ? registry[actualToolName] : null;
if (!tool) {
console.log(`Tool not found: ${toolCall.name} (mapped to ${actualToolName})`);
continue;
}
// Create a run
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: "t_chat",
tool: actualToolName,
status: "queued",
created_at: now,
updated_at: now,
input: toolCall.arguments,
artifacts: { bucket: "", prefix: `runs/${runId}` }
};
await saveRun(run);
// Execute the tool
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const response = await fetch(execUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
run_id: runId,
tenant_id: "t_chat",
input: toolCall.arguments
})
});
if (!response.ok) {
throw new Error(`Executor error: ${response.status}`);
}
const output = await response.json();
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
} catch (e: any) {
run.status = "failed";
run.error = { message: e.message };
run.updated_at = new Date().toISOString();
await saveRun(run);
}
runs.push(run);
}
return runs;
}
/**
* Handle code generation specially
*/
async function handleCodeGeneration(args: any): Promise<RunRecord> {
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
// For now, return a mock code generation result
// In production, this would call Gemini again with a code-specific prompt
const mockDiff = `--- a/${args.file_path || "src/example.ts"}
+++ b/${args.file_path || "src/example.ts"}
@@ -1,3 +1,10 @@
+// Generated by Product OS
+// Task: ${args.task}
+
export function example() {
- return "hello";
+ // TODO: Implement ${args.task}
+ return {
+ status: "generated",
+ task: "${args.task}"
+ };
}`;
const run: RunRecord = {
run_id: runId,
tenant_id: "t_chat",
tool: "code.generate",
status: "succeeded",
created_at: now,
updated_at: now,
input: args,
output: {
type: "code_generation",
diff: mockDiff,
file_path: args.file_path || "src/example.ts",
language: args.language || "typescript",
description: `Generated code for: ${args.task}`
}
};
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "diff.patch", mockDiff);
return run;
}
/**
* Generate a human-readable summary of tool executions
*/
function generateToolSummary(toolCalls: ToolCall[], runs: RunRecord[]): string {
const parts: string[] = [];
for (let i = 0; i < toolCalls.length; i++) {
const tool = toolCalls[i];
const run = runs[i];
if (!run) continue;
const status = run.status === "succeeded" ? "✅" : "❌";
switch (tool.name) {
case "deploy_service":
if (run.status === "succeeded") {
const output = run.output as any;
parts.push(`${status} **Deployed** \`${tool.arguments.service_name}\` to ${tool.arguments.env || "dev"}\n URL: ${output?.service_url || "pending"}`);
} else {
parts.push(`${status} **Deploy failed** for \`${tool.arguments.service_name}\`: ${run.error?.message}`);
}
break;
case "get_funnel_analytics":
if (run.status === "succeeded") {
const output = run.output as any;
const steps = output?.steps || [];
const conversion = ((output?.overall_conversion || 0) * 100).toFixed(1);
parts.push(`${status} **Funnel Analysis** (${tool.arguments.range_days || 30} days)\n Overall conversion: ${conversion}%\n Steps: ${steps.length}`);
}
break;
case "get_top_drivers":
if (run.status === "succeeded") {
const output = run.output as any;
const drivers = output?.drivers || [];
const topDrivers = drivers.slice(0, 3).map((d: any) => d.name).join(", ");
parts.push(`${status} **Top Drivers** for ${tool.arguments.metric}\n ${topDrivers}`);
}
break;
case "generate_marketing_posts":
if (run.status === "succeeded") {
const output = run.output as any;
const channels = output?.channels || [];
const postCount = channels.reduce((sum: number, c: any) => sum + (c.posts?.length || 0), 0);
parts.push(`${status} **Generated** ${postCount} marketing posts for ${channels.map((c: any) => c.channel).join(", ")}`);
}
break;
case "get_service_status":
if (run.status === "succeeded") {
const output = run.output as any;
parts.push(`${status} **Service Status**: \`${tool.arguments.service_name}\` is ${output?.status || "unknown"}`);
}
break;
case "generate_code":
if (run.status === "succeeded") {
parts.push(`${status} **Generated code** for: ${tool.arguments.task}\n File: \`${(run.output as any)?.file_path}\``);
}
break;
default:
parts.push(`${status} **${tool.name}** completed`);
}
}
if (parts.length === 0) {
return "I processed your request but no actions were taken.";
}
return parts.join("\n\n");
}

View File

@@ -0,0 +1,17 @@
import type { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) {
// Root route - API info
app.get("/", async () => ({
name: "Product OS Control Plane",
version: "0.1.0",
endpoints: {
health: "GET /healthz",
tools: "GET /tools",
invoke: "POST /tools/invoke",
runs: "GET /runs/:run_id"
}
}));
app.get("/healthz", async () => ({ ok: true }));
}

View File

@@ -0,0 +1,18 @@
import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { getRun } from "../storage/index.js";
export async function runRoutes(app: FastifyInstance) {
app.get("/runs/:run_id", async (req) => {
await requireAuth(req);
const runId = (req.params as any).run_id as string;
const run = await getRun(runId);
if (!run) return app.httpErrors.notFound("Run not found");
return run;
});
app.get("/runs/:run_id/logs", async (req) => {
await requireAuth(req);
return { note: "V1: logs are in GCS artifacts under runs/<run_id>/" };
});
}

View File

@@ -0,0 +1,91 @@
import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { getRegistry } from "../registry.js";
import { saveRun, writeArtifactText } from "../storage/index.js";
import type { RunRecord, ToolInvokeRequest } from "../types.js";
async function postJson(url: string, body: unknown) {
const res = await fetch(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body)
});
if (!res.ok) {
const txt = await res.text();
throw new Error(`Executor error ${res.status}: ${txt}`);
}
return res.json() as Promise<unknown>;
}
export async function toolRoutes(app: FastifyInstance) {
app.get("/tools", async (req) => {
await requireAuth(req);
const registry = await getRegistry();
return { tools: Object.values(registry) };
});
app.post<{ Body: ToolInvokeRequest }>("/tools/invoke", async (req) => {
await requireAuth(req);
const body = req.body;
const registry = await getRegistry();
const tool = registry[body.tool];
if (!tool) return app.httpErrors.notFound(`Unknown tool: ${body.tool}`);
const runId = `run_${new Date().toISOString().replace(/[-:.TZ]/g, "")}_${nanoid(8)}`;
const now = new Date().toISOString();
const run: RunRecord = {
run_id: runId,
tenant_id: body.tenant_id,
tool: body.tool,
status: "queued",
created_at: now,
updated_at: now,
input: body.input,
artifacts: { bucket: process.env.GCS_BUCKET_ARTIFACTS ?? "", prefix: `runs/${runId}` }
};
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2));
try {
run.status = "running";
run.updated_at = new Date().toISOString();
await saveRun(run);
if (body.dry_run) {
run.status = "succeeded";
run.output = { dry_run: true };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(run.output, null, 2));
return { run_id: runId, status: run.status };
}
const execUrl = `${tool.executor.url}${tool.executor.path}`;
const output = await postJson(execUrl, {
run_id: runId,
tenant_id: body.tenant_id,
workspace_id: body.workspace_id,
input: body.input
});
run.status = "succeeded";
run.output = output;
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "output.json", JSON.stringify(output, null, 2));
return { run_id: runId, status: run.status };
} catch (e: any) {
run.status = "failed";
run.error = { message: e?.message ?? "Unknown error" };
run.updated_at = new Date().toISOString();
await saveRun(run);
await writeArtifactText(`runs/${runId}`, "error.json", JSON.stringify(run.error, null, 2));
return { run_id: runId, status: run.status };
}
});
}

View File

@@ -0,0 +1,23 @@
import { Firestore } from "@google-cloud/firestore";
import { config } from "../config.js";
import type { RunRecord, ToolDef } from "../types.js";
const db = new Firestore({ projectId: config.projectId });
export async function saveRun(run: RunRecord): Promise<void> {
await db.collection(config.runsCollection).doc(run.run_id).set(run, { merge: true });
}
export async function getRun(runId: string): Promise<RunRecord | null> {
const snap = await db.collection(config.runsCollection).doc(runId).get();
return snap.exists ? (snap.data() as RunRecord) : null;
}
export async function saveTool(tool: ToolDef): Promise<void> {
await db.collection(config.toolsCollection).doc(tool.name).set(tool, { merge: true });
}
export async function listTools(): Promise<ToolDef[]> {
const snap = await db.collection(config.toolsCollection).get();
return snap.docs.map(d => d.data() as ToolDef);
}

View File

@@ -0,0 +1,11 @@
import { Storage } from "@google-cloud/storage";
import { config } from "../config.js";
const storage = new Storage({ projectId: config.projectId });
export async function writeArtifactText(prefix: string, filename: string, content: string) {
const bucket = storage.bucket(config.artifactsBucket);
const file = bucket.file(`${prefix}/${filename}`);
await file.save(content, { contentType: "text/plain" });
return { bucket: config.artifactsBucket, path: `${prefix}/${filename}` };
}

View File

@@ -0,0 +1,23 @@
/**
* Storage adapter that switches between GCP (Firestore/GCS) and in-memory
*/
import { config } from "../config.js";
import * as memory from "./memory.js";
import * as firestore from "./firestore.js";
import * as gcs from "./gcs.js";
const useMemory = config.storageMode === "memory";
if (useMemory) {
console.log("💾 Using in-memory storage (set GCP_PROJECT_ID for Firestore/GCS)");
memory.seedTools();
} else {
console.log(`☁️ Using GCP storage (project: ${config.projectId})`);
}
// Export unified interface
export const saveRun = useMemory ? memory.saveRun : firestore.saveRun;
export const getRun = useMemory ? memory.getRun : firestore.getRun;
export const saveTool = useMemory ? memory.saveTool : firestore.saveTool;
export const listTools = useMemory ? memory.listTools : firestore.listTools;
export const writeArtifactText = useMemory ? memory.writeArtifactText : gcs.writeArtifactText;

View File

@@ -0,0 +1,116 @@
/**
* In-memory storage for local development without Firestore/GCS
*/
import type { RunRecord, ToolDef } from "../types.js";
// In-memory stores
const runs = new Map<string, RunRecord>();
const tools = new Map<string, ToolDef>();
const artifacts = new Map<string, string>();
// Run operations
export async function saveRun(run: RunRecord): Promise<void> {
runs.set(run.run_id, { ...run });
}
export async function getRun(runId: string): Promise<RunRecord | null> {
return runs.get(runId) ?? null;
}
// Tool operations
export async function saveTool(tool: ToolDef): Promise<void> {
tools.set(tool.name, { ...tool });
}
export async function listTools(): Promise<ToolDef[]> {
return Array.from(tools.values());
}
// Artifact operations
export async function writeArtifactText(prefix: string, filename: string, content: string) {
const path = `${prefix}/${filename}`;
artifacts.set(path, content);
return { bucket: "memory", path };
}
// Seed some example tools for testing
export function seedTools() {
const sampleTools: ToolDef[] = [
{
name: "cloudrun.deploy_service",
description: "Build and deploy a Cloud Run service",
risk: "medium",
executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/deploy" },
inputSchema: {
type: "object",
required: ["service_name", "repo", "ref", "env"],
properties: {
service_name: { type: "string" },
repo: { type: "string" },
ref: { type: "string" },
env: { type: "string", enum: ["dev", "staging", "prod"] }
}
}
},
{
name: "cloudrun.get_service_status",
description: "Get Cloud Run service status",
risk: "low",
executor: { kind: "http", url: "http://localhost:8090", path: "/execute/cloudrun/status" },
inputSchema: {
type: "object",
required: ["service_name", "region"],
properties: {
service_name: { type: "string" },
region: { type: "string" }
}
}
},
{
name: "analytics.funnel_summary",
description: "Get funnel metrics for a time window",
risk: "low",
executor: { kind: "http", url: "http://localhost:8091", path: "/execute/analytics/funnel" },
inputSchema: {
type: "object",
required: ["range_days"],
properties: {
range_days: { type: "integer", minimum: 1, maximum: 365 }
}
}
},
{
name: "brand.get_profile",
description: "Get tenant brand profile",
risk: "low",
executor: { kind: "http", url: "http://localhost:8092", path: "/execute/brand/get" },
inputSchema: {
type: "object",
required: ["profile_id"],
properties: {
profile_id: { type: "string" }
}
}
},
{
name: "marketing.generate_channel_posts",
description: "Generate social posts from a brief",
risk: "low",
executor: { kind: "http", url: "http://localhost:8093", path: "/execute/marketing/generate" },
inputSchema: {
type: "object",
required: ["brief", "channels"],
properties: {
brief: { type: "object" },
channels: { type: "array", items: { type: "string" } }
}
}
}
];
for (const tool of sampleTools) {
tools.set(tool.name, tool);
}
console.log(`📦 Seeded ${sampleTools.length} tools in memory`);
}

View File

@@ -0,0 +1,37 @@
export type ToolRisk = "low" | "medium" | "high";
export type ToolDef = {
name: string;
description: string;
risk: ToolRisk;
executor: {
kind: "http";
url: string;
path: string;
};
inputSchema: unknown;
outputSchema?: unknown;
};
export type ToolInvokeRequest = {
tool: string;
tenant_id: string;
workspace_id?: string;
input: unknown;
dry_run?: boolean;
};
export type RunStatus = "queued" | "running" | "succeeded" | "failed";
export type RunRecord = {
run_id: string;
tenant_id: string;
tool: string;
status: RunStatus;
created_at: string;
updated_at: string;
input: unknown;
output?: unknown;
error?: { message: string; details?: unknown };
artifacts?: { bucket: string; prefix: string };
};

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "@productos/analytics-executor",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,91 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(sensible);
app.get("/healthz", async () => ({ ok: true, executor: "analytics" }));
/**
* Get funnel summary
* In production: queries BigQuery
*/
app.post("/execute/analytics/funnel", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`📊 Funnel request:`, input);
// Mock funnel data
const steps = [
{ event_name: "page_view", users: 10000, conversion_from_prev: 1.0 },
{ event_name: "signup_start", users: 3200, conversion_from_prev: 0.32 },
{ event_name: "signup_complete", users: 2100, conversion_from_prev: 0.66 },
{ event_name: "first_action", users: 1400, conversion_from_prev: 0.67 },
{ event_name: "subscription", users: 420, conversion_from_prev: 0.30 }
];
return {
funnel_name: input.funnel?.name ?? "default_funnel",
range_days: input.range_days,
steps,
overall_conversion: 0.042,
generated_at: new Date().toISOString()
};
});
/**
* Get top drivers for a metric
*/
app.post("/execute/analytics/top_drivers", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`🔍 Top drivers request:`, input);
// Mock driver analysis
const drivers = [
{ name: "completed_onboarding", score: 0.85, direction: "positive", evidence: "Users who complete onboarding convert 3.2x more", confidence: 0.92 },
{ name: "used_feature_x", score: 0.72, direction: "positive", evidence: "Feature X usage correlates with 2.5x retention", confidence: 0.88 },
{ name: "time_to_first_value", score: 0.68, direction: "negative", evidence: "Each additional day reduces conversion by 12%", confidence: 0.85 },
{ name: "invited_team_member", score: 0.61, direction: "positive", evidence: "Team invites increase stickiness by 40%", confidence: 0.79 },
{ name: "mobile_signup", score: 0.45, direction: "negative", evidence: "Mobile signups have 25% lower activation", confidence: 0.71 }
];
return {
target: input.target,
range_days: input.range_days,
drivers,
generated_at: new Date().toISOString()
};
});
/**
* Write an insight
*/
app.post("/execute/analytics/write_insight", async (req) => {
const body = req.body as any;
const { input, run_id } = body;
console.log(`💡 Write insight:`, input.insight?.title);
const insightId = `insight_${Date.now().toString(36)}`;
return {
insight_id: insightId,
stored: {
bigquery: { dataset: "insights", table: "insights_v1" },
firestore: { collection: "insights", doc_id: insightId },
gcs: { bucket: "productos-artifacts", prefix: `insights/${insightId}` }
},
created_at: new Date().toISOString()
};
});
const port = Number(process.env.PORT ?? 8091);
app.listen({ port, host: "0.0.0.0" }).then(() => {
console.log(`📈 Analytics Executor running on http://localhost:${port}`);
});

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -0,0 +1,23 @@
{
"name": "@productos/deploy-executor",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,91 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(sensible);
// Health check
app.get("/healthz", async () => ({ ok: true, executor: "deploy" }));
/**
* Deploy a Cloud Run service
* In production: triggers Cloud Build, deploys to Cloud Run
* In dev: returns mock response
*/
app.post("/execute/cloudrun/deploy", async (req) => {
const body = req.body as any;
const { run_id, tenant_id, input } = body;
console.log(`🚀 Deploy request:`, { run_id, tenant_id, input });
// Simulate deployment time
await new Promise(r => setTimeout(r, 1500));
// In production, this would:
// 1. Clone the repo
// 2. Trigger Cloud Build
// 3. Deploy to Cloud Run
// 4. Return the service URL
const mockRevision = `${input.service_name}-${Date.now().toString(36)}`;
const mockUrl = `https://${input.service_name}-abc123.a.run.app`;
console.log(`✅ Deploy complete:`, { revision: mockRevision, url: mockUrl });
return {
service_url: mockUrl,
revision: mockRevision,
build_id: `build-${Date.now()}`,
deployed_at: new Date().toISOString(),
region: input.region ?? "us-central1",
env: input.env
};
});
/**
* Get Cloud Run service status
*/
app.post("/execute/cloudrun/status", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`📊 Status request:`, input);
// Mock status response
return {
service_name: input.service_name,
region: input.region,
service_url: `https://${input.service_name}-abc123.a.run.app`,
latest_ready_revision: `${input.service_name}-v1`,
status: "ready",
last_deploy_time: new Date().toISOString(),
traffic: [{ revision: `${input.service_name}-v1`, percent: 100 }]
};
});
/**
* Rollback to a previous revision
*/
app.post("/execute/cloudrun/rollback", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`⏪ Rollback request:`, input);
await new Promise(r => setTimeout(r, 1000));
return {
service_name: input.service_name,
rolled_back_to: input.target_revision ?? "previous",
status: "ready",
rolled_back_at: new Date().toISOString()
};
});
const port = Number(process.env.PORT ?? 8090);
app.listen({ port, host: "0.0.0.0" }).then(() => {
console.log(`🔧 Deploy Executor running on http://localhost:${port}`);
});

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "@productos/marketing-executor",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@fastify/cors": "^10.0.0",
"@fastify/sensible": "^6.0.0",
"fastify": "^5.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,88 @@
import Fastify from "fastify";
import cors from "@fastify/cors";
import sensible from "@fastify/sensible";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(sensible);
app.get("/healthz", async () => ({ ok: true, executor: "marketing" }));
/**
* Generate channel posts from a brief
* In production: calls Gemini API
*/
app.post("/execute/marketing/generate", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`✍️ Generate posts:`, input.brief?.goal);
await new Promise(r => setTimeout(r, 1000)); // Simulate AI generation time
const channels = (input.channels ?? ["x", "linkedin"]).map((channel: string) => ({
channel,
posts: [
{
text: `🚀 Exciting news! ${input.brief?.product ?? "Our product"} just got even better. ${input.brief?.key_points?.[0] ?? "Check it out!"}\n\n${input.brief?.call_to_action ?? "Learn more"} 👇\n${input.brief?.landing_page_url ?? "https://example.com"}`,
hashtags: ["#ProductUpdate", "#SaaS", "#Innovation"],
media_suggestions: ["product-screenshot.png", "feature-demo.gif"]
},
{
text: `${input.brief?.audience ?? "Teams"} asked, we listened! Introducing ${input.brief?.product ?? "new features"} that will transform how you work.\n\n✨ ${input.brief?.key_points?.join("\n✨ ") ?? "Amazing new capabilities"}\n\nTry it today!`,
hashtags: ["#ProductLaunch", "#Productivity"],
media_suggestions: ["comparison-chart.png"]
},
{
text: `Did you know? ${input.brief?.key_points?.[0] ?? "Our latest update"} can save you hours every week.\n\nHere's how ${input.brief?.product ?? "it"} works:\n1⃣ Set it up in minutes\n2⃣ Let automation do the work\n3⃣ Focus on what matters\n\n${input.brief?.offer ?? "Start free today!"}`,
hashtags: ["#Automation", "#WorkSmarter"],
media_suggestions: ["how-it-works.mp4"]
}
]
}));
return {
channels,
brief_summary: input.brief?.goal,
generated_at: new Date().toISOString(),
variations_per_channel: 3
};
});
/**
* Get brand profile
*/
app.post("/execute/brand/get", async (req) => {
const body = req.body as any;
const { input } = body;
console.log(`🎨 Get brand profile:`, input.profile_id);
return {
profile_id: input.profile_id ?? "default",
brand: {
name: "Product OS",
voice: {
tone: ["professional", "friendly", "innovative"],
style_notes: ["Use active voice", "Keep sentences short", "Be specific with benefits"],
do: ["Use data and metrics", "Include calls to action", "Highlight customer success"],
dont: ["Make unverified claims", "Use jargon", "Be overly salesy"]
},
audience: {
primary: "SaaS founders and product teams",
secondary: "Growth marketers and developers"
},
claims_policy: {
forbidden_claims: ["#1 in the market", "Guaranteed results"],
required_disclaimers: [],
compliance_notes: ["All metrics should be verifiable"]
}
}
};
});
const port = Number(process.env.PORT ?? 8093);
app.listen({ port, host: "0.0.0.0" }).then(() => {
console.log(`📣 Marketing Executor running on http://localhost:${port}`);
});

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}

View File

@@ -0,0 +1,103 @@
# Product OS MCP Adapter
Exposes Control Plane tools to Continue and other MCP clients.
## Architecture
```
Continue (MCP client)
MCP Adapter (this server)
Control Plane API
Gemini + Executors
```
## Available Tools
| Tool | Description |
|------|-------------|
| `deploy_service` | Deploy a Cloud Run service |
| `get_service_status` | Check deployment health |
| `get_funnel_analytics` | Get conversion metrics |
| `get_top_drivers` | Understand metric drivers |
| `generate_marketing_posts` | Create social content |
| `chat_with_gemini` | General AI conversation |
## Setup
### 1. Install dependencies
```bash
cd platform/backend/mcp-adapter
npm install
```
### 2. Make sure Control Plane is running
```bash
cd platform/backend/control-plane
npm run dev
```
### 3. Install Continue Extension
In VS Code/VSCodium:
1. Open Extensions (Cmd+Shift+X)
2. Search for "Continue"
3. Install
### 4. Configure Continue
The configuration is already in `.continue/config.yaml`.
Or manually add to your Continue config:
```yaml
experimental:
modelContextProtocolServers:
- name: productos
command: npx
args:
- tsx
- /path/to/platform/backend/mcp-adapter/src/index.ts
env:
CONTROL_PLANE_URL: http://localhost:8080
```
### 5. Use in Continue
1. Open Continue chat (Cmd+L)
2. Enable Agent mode (click the robot icon)
3. Ask: "Deploy my service to staging"
## Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `CONTROL_PLANE_URL` | `http://localhost:8080` | Control Plane API URL |
| `TENANT_ID` | `t_mcp` | Tenant ID for tool calls |
## Development
```bash
# Run directly (for testing)
npm run dev
# Build
npm run build
# Run built version
npm start
```
## Testing
```bash
# Test tool listing
echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | npm run dev
# Test tool call
echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_funnel_analytics","arguments":{}}}' | npm run dev
```

View File

@@ -0,0 +1,25 @@
{
"name": "@productos/mcp-adapter",
"version": "0.1.0",
"private": true,
"description": "MCP Adapter Server - exposes Control Plane tools to Continue and other MCP clients",
"type": "module",
"main": "dist/index.js",
"bin": {
"productos-mcp": "./dist/index.js"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^22.0.0",
"tsx": "^4.19.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,343 @@
#!/usr/bin/env node
/**
* Product OS MCP Adapter Server
*
* Exposes Control Plane tools to Continue and other MCP clients.
*
* Architecture:
* Continue (MCP client) → This Adapter → Control Plane → Gemini + Executors
*
* This keeps:
* - Control Plane as the canonical API
* - Auth/billing/policies centralized
* - MCP as a compatibility layer
*/
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import {
CallToolRequestSchema,
ListToolsRequestSchema,
ToolSchema,
} from "@modelcontextprotocol/sdk/types.js";
import { z } from "zod";
// Control Plane URL (configurable via env)
const CONTROL_PLANE_URL = process.env.CONTROL_PLANE_URL || "http://localhost:8080";
const TENANT_ID = process.env.TENANT_ID || "t_mcp";
// ============================================================================
// Tool Definitions
// ============================================================================
const TOOLS: ToolSchema[] = [
{
name: "deploy_service",
description: "Deploy a Cloud Run service to GCP. Use when the user wants to deploy, ship, or launch code to staging or production.",
inputSchema: {
type: "object",
properties: {
service_name: {
type: "string",
description: "Name of the service to deploy"
},
repo: {
type: "string",
description: "Git repository URL (optional, defaults to current workspace)"
},
ref: {
type: "string",
description: "Git branch, tag, or commit (optional, defaults to main)"
},
env: {
type: "string",
enum: ["dev", "staging", "prod"],
description: "Target environment"
}
},
required: ["service_name"]
}
},
{
name: "get_service_status",
description: "Check the status of a deployed Cloud Run service. Use when the user asks about service health or deployment status.",
inputSchema: {
type: "object",
properties: {
service_name: {
type: "string",
description: "Name of the service to check"
},
region: {
type: "string",
description: "GCP region (defaults to us-central1)"
}
},
required: ["service_name"]
}
},
{
name: "get_funnel_analytics",
description: "Get funnel conversion metrics and drop-off analysis. Use when the user asks about funnels, conversions, or user journey.",
inputSchema: {
type: "object",
properties: {
funnel_name: {
type: "string",
description: "Name of the funnel to analyze (optional)"
},
range_days: {
type: "integer",
description: "Number of days to analyze (defaults to 30)"
}
}
}
},
{
name: "get_top_drivers",
description: "Identify top factors driving a metric. Use when the user asks why something changed or what drives conversions/retention.",
inputSchema: {
type: "object",
properties: {
metric: {
type: "string",
description: "The metric to analyze (e.g., 'conversion', 'retention', 'churn')"
},
range_days: {
type: "integer",
description: "Number of days to analyze (defaults to 30)"
}
},
required: ["metric"]
}
},
{
name: "generate_marketing_posts",
description: "Generate social media posts for a marketing campaign. Use when the user wants to create content for X, LinkedIn, etc.",
inputSchema: {
type: "object",
properties: {
goal: {
type: "string",
description: "Campaign goal (e.g., 'product launch', 'feature announcement', 'engagement')"
},
product: {
type: "string",
description: "Product or feature name"
},
channels: {
type: "array",
items: { type: "string" },
description: "Social channels to generate for (e.g., ['x', 'linkedin'])"
},
tone: {
type: "string",
description: "Tone of voice (e.g., 'professional', 'casual', 'excited')"
}
},
required: ["goal"]
}
},
{
name: "chat_with_gemini",
description: "Have a conversation with Gemini AI about your product, code, or anything else. Use for general questions, code explanation, or ideation.",
inputSchema: {
type: "object",
properties: {
message: {
type: "string",
description: "The message or question to send to Gemini"
},
context: {
type: "string",
description: "Additional context (e.g., code snippet, file contents)"
}
},
required: ["message"]
}
}
];
// ============================================================================
// Tool Name Mapping (MCP tool name → Control Plane tool name)
// ============================================================================
const TOOL_MAPPING: Record<string, string> = {
"deploy_service": "cloudrun.deploy_service",
"get_service_status": "cloudrun.get_service_status",
"get_funnel_analytics": "analytics.funnel_summary",
"get_top_drivers": "analytics.top_drivers",
"generate_marketing_posts": "marketing.generate_channel_posts",
};
// ============================================================================
// Control Plane Client
// ============================================================================
async function invokeControlPlaneTool(tool: string, input: any): Promise<any> {
// Step 1: Invoke the tool
const invokeResponse = await fetch(`${CONTROL_PLANE_URL}/tools/invoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: TENANT_ID,
input,
dry_run: false
})
});
if (!invokeResponse.ok) {
const text = await invokeResponse.text();
throw new Error(`Control Plane error: ${invokeResponse.status} - ${text}`);
}
const invokeResult = await invokeResponse.json();
// Step 2: Fetch the full run to get the output
const runResponse = await fetch(`${CONTROL_PLANE_URL}/runs/${invokeResult.run_id}`);
if (!runResponse.ok) {
// Fall back to the invoke result if we can't fetch the run
return invokeResult;
}
return runResponse.json();
}
async function chatWithControlPlane(message: string, context?: string): Promise<any> {
const messages = [];
if (context) {
messages.push({ role: "user", content: `Context:\n${context}` });
}
messages.push({ role: "user", content: message });
const response = await fetch(`${CONTROL_PLANE_URL}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages,
autoExecuteTools: true
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Control Plane chat error: ${response.status} - ${text}`);
}
return response.json();
}
// ============================================================================
// MCP Server
// ============================================================================
const server = new Server(
{
name: "productos-mcp",
version: "0.1.0",
},
{
capabilities: {
tools: {},
},
}
);
// List available tools
server.setRequestHandler(ListToolsRequestSchema, async () => {
return { tools: TOOLS };
});
// Handle tool calls
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
console.error(`[MCP] Tool called: ${name}`, args);
try {
// Special handling for chat
if (name === "chat_with_gemini") {
const result = await chatWithControlPlane(
(args as any).message,
(args as any).context
);
return {
content: [
{
type: "text",
text: result.message || JSON.stringify(result, null, 2)
}
]
};
}
// Map MCP tool name to Control Plane tool name
const controlPlaneTool = TOOL_MAPPING[name];
if (!controlPlaneTool) {
throw new Error(`Unknown tool: ${name}`);
}
// Invoke the Control Plane
const run = await invokeControlPlaneTool(controlPlaneTool, args);
// Format the response
let responseText = "";
if (run.status === "succeeded") {
responseText = `✅ **${name}** completed successfully\n\n`;
responseText += "**Result:**\n```json\n";
responseText += JSON.stringify(run.output, null, 2);
responseText += "\n```";
} else if (run.status === "failed") {
responseText = `❌ **${name}** failed\n\n`;
responseText += `**Error:** ${run.error?.message || "Unknown error"}`;
} else {
responseText = `⏳ **${name}** is ${run.status}\n\n`;
responseText += `**Run ID:** ${run.run_id}`;
}
return {
content: [
{
type: "text",
text: responseText
}
]
};
} catch (error: any) {
console.error(`[MCP] Error:`, error);
return {
content: [
{
type: "text",
text: `❌ Error executing ${name}: ${error.message}`
}
],
isError: true
};
}
});
// ============================================================================
// Start Server
// ============================================================================
async function main() {
console.error("[MCP] Product OS MCP Adapter starting...");
console.error(`[MCP] Control Plane URL: ${CONTROL_PLANE_URL}`);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("[MCP] Server connected and ready");
}
main().catch((error) => {
console.error("[MCP] Fatal error:", error);
process.exit(1);
});

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"declaration": true
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
<path d="M2 17l10 5 10-5"/>
<path d="M2 12l10 5 10-5"/>
</svg>

After

Width:  |  Height:  |  Size: 266 B

View File

@@ -0,0 +1,97 @@
{
"name": "gcp-productos",
"displayName": "GCP Product OS",
"description": "Product-centric IDE for launching and operating SaaS products on Google Cloud.",
"version": "0.1.0",
"publisher": "productos",
"engines": { "vscode": "^1.90.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"contributes": {
"viewsContainers": {
"activitybar": [
{
"id": "productos",
"title": "Product OS",
"icon": "media/icon.svg"
}
]
},
"views": {
"productos": [
{
"id": "productos.chat",
"type": "webview",
"name": "Chat",
"icon": "$(comment-discussion)"
},
{
"id": "productos.tools",
"name": "Tools",
"icon": "media/icon.svg"
},
{
"id": "productos.runs",
"name": "Recent Runs"
}
]
},
"viewsWelcome": [
{
"view": "productos.tools",
"contents": "Connect to Product OS backend to see available tools.\n[Configure Backend](command:productos.configure)"
}
],
"commands": [
{ "command": "productos.chat", "title": "Product OS: Open Chat", "icon": "$(comment-discussion)" },
{ "command": "productos.configure", "title": "Product OS: Configure Backend", "icon": "$(gear)" },
{ "command": "productos.refresh", "title": "Product OS: Refresh", "icon": "$(refresh)" },
{ "command": "productos.tools.list", "title": "Product OS: List Tools" },
{ "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool", "icon": "$(play)" },
{ "command": "productos.tools.invokeFromTree", "title": "Invoke Tool", "icon": "$(play)" },
{ "command": "productos.runs.open", "title": "Product OS: Open Run" },
{ "command": "productos.runs.openFromTree", "title": "View Run Details", "icon": "$(eye)" },
{ "command": "productos.openPanel", "title": "Product OS: Open Panel" }
],
"keybindings": [
{ "command": "productos.chat", "key": "ctrl+shift+p", "mac": "cmd+shift+p", "when": "editorTextFocus" }
],
"menus": {
"view/title": [
{ "command": "productos.refresh", "when": "view == productos.tools", "group": "navigation" },
{ "command": "productos.configure", "when": "view == productos.tools" }
],
"view/item/context": [
{ "command": "productos.tools.invokeFromTree", "when": "viewItem == tool", "group": "inline" },
{ "command": "productos.runs.openFromTree", "when": "viewItem == run", "group": "inline" }
]
},
"configuration": {
"title": "Product OS",
"properties": {
"productos.backendUrl": {
"type": "string",
"default": "http://localhost:8080",
"description": "Control Plane API base URL"
},
"productos.tenantId": {
"type": "string",
"default": "t_dev",
"description": "Tenant ID for tool calls"
}
}
}
},
"scripts": {
"build": "tsc -p tsconfig.json",
"watch": "tsc -w -p tsconfig.json",
"package": "vsce package --allow-missing-repository"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"@vscode/vsce": "^3.0.0",
"typescript": "^5.5.4"
}
}

View File

@@ -0,0 +1,137 @@
import * as vscode from "vscode";
export interface Tool {
name: string;
description: string;
risk: "low" | "medium" | "high";
executor: {
kind: string;
url: string;
path: string;
};
inputSchema: any;
outputSchema?: any;
}
export interface Run {
run_id: string;
tenant_id: string;
tool: string;
status: "queued" | "running" | "succeeded" | "failed";
created_at: string;
updated_at: string;
input: any;
output?: any;
error?: { message: string; details?: any };
}
function getConfig<T>(key: string): T {
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
}
export function getBackendUrl(): string {
return getConfig<string>("backendUrl");
}
export function getTenantId(): string {
return getConfig<string>("tenantId");
}
export async function checkConnection(): Promise<boolean> {
try {
const res = await fetch(`${getBackendUrl()}/healthz`, {
signal: AbortSignal.timeout(3000)
});
return res.ok;
} catch {
return false;
}
}
export async function listTools(): Promise<Tool[]> {
const res = await fetch(`${getBackendUrl()}/tools`);
if (!res.ok) throw new Error(await res.text());
const json = await res.json();
return json.tools ?? [];
}
export async function invokeTool(tool: string, input: any, dryRun = false): Promise<Run> {
const res = await fetch(`${getBackendUrl()}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: getTenantId(),
input,
dry_run: dryRun
})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function getRun(runId: string): Promise<Run> {
const res = await fetch(`${getBackendUrl()}/runs/${runId}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
// Store recent runs in memory
const recentRuns: Run[] = [];
export function addRecentRun(run: Run) {
recentRuns.unshift(run);
if (recentRuns.length > 20) recentRuns.pop();
}
export function getRecentRuns(): Run[] {
return [...recentRuns];
}
// Chat types
export interface ChatMessage {
role: "user" | "assistant" | "system";
content: string;
}
export interface ToolCall {
name: string;
arguments: Record<string, any>;
}
export interface ChatResponse {
message: string;
toolCalls?: ToolCall[];
runs?: Run[];
finishReason: "stop" | "tool_calls" | "error";
}
export interface ChatContext {
files?: { path: string; content: string }[];
selection?: { path: string; text: string; startLine: number };
}
/**
* Chat with the AI backend
*/
export async function chatWithAI(
messages: ChatMessage[],
context?: ChatContext
): Promise<ChatResponse> {
const res = await fetch(`${getBackendUrl()}/chat`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
messages,
context,
autoExecuteTools: true
})
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Chat failed: ${text}`);
}
return res.json();
}

View File

@@ -0,0 +1,850 @@
import * as vscode from "vscode";
import { chatWithAI, ChatMessage, ChatResponse } from "./api";
/**
* Product OS Chat Panel
* A Cursor-like conversational AI interface
*/
export class ChatPanel {
public static currentPanel: ChatPanel | undefined;
private static readonly viewType = "productosChat";
private readonly _panel: vscode.WebviewPanel;
private readonly _extensionUri: vscode.Uri;
private _disposables: vscode.Disposable[] = [];
private _messages: ChatMessage[] = [];
public static createOrShow(extensionUri: vscode.Uri) {
const column = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
// If we already have a panel, show it
if (ChatPanel.currentPanel) {
ChatPanel.currentPanel._panel.reveal(column);
return;
}
// Otherwise, create a new panel
const panel = vscode.window.createWebviewPanel(
ChatPanel.viewType,
"Product OS Chat",
column || vscode.ViewColumn.One,
{
enableScripts: true,
retainContextWhenHidden: true,
localResourceRoots: [vscode.Uri.joinPath(extensionUri, "media")]
}
);
ChatPanel.currentPanel = new ChatPanel(panel, extensionUri);
}
private constructor(panel: vscode.WebviewPanel, extensionUri: vscode.Uri) {
this._panel = panel;
this._extensionUri = extensionUri;
// Set the webview's initial html content
this._update();
// Listen for when the panel is disposed
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
// Handle messages from the webview
this._panel.webview.onDidReceiveMessage(
async (message) => {
switch (message.command) {
case "send":
await this._handleChat(message.text);
return;
case "addContext":
await this._handleAddContext();
return;
case "clear":
this._messages = [];
this._update();
return;
}
},
null,
this._disposables
);
}
private async _handleChat(text: string) {
// Add user message to history (webview already shows it)
this._messages.push({ role: "user", content: text });
// DON'T call _update() - it would reset the webview and kill the JS state
// Show loading state
this._panel.webview.postMessage({ type: "loading", loading: true });
try {
// Get context from active editor
const context = this._getEditorContext();
console.log("[Product OS Chat] Sending to API:", text);
// Call the AI
const response = await chatWithAI(this._messages, context);
console.log("[Product OS Chat] Response:", response);
// Add assistant response to history
this._messages.push({ role: "assistant", content: response.message || "" });
// Send response to webview
this._panel.webview.postMessage({
type: "response",
message: response.message,
toolCalls: response.toolCalls,
runs: response.runs
});
} catch (error: any) {
console.error("[Product OS Chat] Error:", error);
this._panel.webview.postMessage({
type: "error",
error: error.message || "Unknown error"
});
} finally {
this._panel.webview.postMessage({ type: "loading", loading: false });
}
}
private async _handleAddContext() {
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("No active editor");
return;
}
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const startLine = selection.start.line + 1;
this._panel.webview.postMessage({
type: "contextAdded",
context: {
type: "selection",
path: filePath,
startLine,
text: selectedText
}
});
} else {
// No selection, add the whole file
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const content = editor.document.getText();
this._panel.webview.postMessage({
type: "contextAdded",
context: {
type: "file",
path: filePath,
text: content.substring(0, 5000) // Limit to first 5000 chars
}
});
}
}
private _getEditorContext(): any {
const editor = vscode.window.activeTextEditor;
if (!editor) return undefined;
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
return {
selection: {
path: vscode.workspace.asRelativePath(editor.document.uri),
text: selectedText,
startLine: selection.start.line + 1
}
};
}
return undefined;
}
public dispose() {
ChatPanel.currentPanel = undefined;
// Clean up resources
this._panel.dispose();
while (this._disposables.length) {
const x = this._disposables.pop();
if (x) {
x.dispose();
}
}
}
private _update() {
this._panel.webview.html = this._getHtmlForWebview();
}
private _getHtmlForWebview() {
const nonce = getNonce();
// Convert messages to HTML
const messagesHtml = this._messages.map(m => {
const isUser = m.role === "user";
const avatarClass = isUser ? "user-avatar" : "ai-avatar";
const messageClass = isUser ? "user-message" : "ai-message";
const avatar = isUser ? "U" : "✦";
return `
<div class="message ${messageClass}">
<div class="avatar ${avatarClass}">${avatar}</div>
<div class="content">${escapeHtml(m.content)}</div>
</div>
`;
}).join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<title>Product OS Chat</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--accent: #58a6ff;
--accent-muted: #1f6feb;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--text-muted: #6e7681;
--border: #30363d;
--success: #3fb950;
--warning: #d29922;
--danger: #f85149;
--gradient-start: #0d1117;
--gradient-end: #161b22;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
background: linear-gradient(180deg, var(--gradient-start) 0%, var(--gradient-end) 100%);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
}
.header-title {
display: flex;
align-items: center;
gap: 12px;
}
.header-title h1 {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.02em;
}
.header-icon {
width: 28px;
height: 28px;
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.header-actions {
display: flex;
gap: 8px;
}
.header-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 12px;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
transition: all 0.15s ease;
}
.header-btn:hover {
background: var(--border);
color: var(--text-primary);
}
.messages {
flex: 1;
overflow-y: auto;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
gap: 12px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.avatar {
width: 32px;
height: 32px;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
flex-shrink: 0;
}
.user-avatar {
background: var(--bg-tertiary);
color: var(--text-secondary);
}
.ai-avatar {
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
color: white;
}
.content {
flex: 1;
padding: 12px 16px;
border-radius: 12px;
line-height: 1.6;
font-size: 14px;
}
.user-message .content {
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.ai-message .content {
background: var(--bg-secondary);
border: 1px solid var(--border);
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.empty-icon {
width: 64px;
height: 64px;
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
border-radius: 16px;
display: flex;
align-items: center;
justify-content: center;
font-size: 28px;
margin-bottom: 20px;
box-shadow: 0 8px 32px rgba(88, 166, 255, 0.3);
}
.empty-state h2 {
font-size: 20px;
font-weight: 600;
margin-bottom: 8px;
letter-spacing: -0.02em;
}
.empty-state p {
color: var(--text-secondary);
font-size: 14px;
max-width: 360px;
line-height: 1.6;
}
.suggestions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
justify-content: center;
}
.suggestion {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 20px;
padding: 8px 16px;
font-size: 13px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.suggestion:hover {
background: var(--border);
color: var(--text-primary);
border-color: var(--accent-muted);
}
.input-area {
padding: 16px 20px 24px;
background: rgba(13, 17, 23, 0.8);
backdrop-filter: blur(10px);
border-top: 1px solid var(--border);
}
.context-badge {
display: none;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 12px;
font-size: 12px;
color: var(--text-secondary);
}
.context-badge.active {
display: flex;
}
.context-badge code {
color: var(--accent);
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.context-close {
margin-left: auto;
cursor: pointer;
opacity: 0.6;
}
.context-close:hover {
opacity: 1;
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
}
.input-container {
flex: 1;
position: relative;
}
textarea {
width: 100%;
min-height: 48px;
max-height: 200px;
padding: 14px 16px;
padding-right: 44px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
color: var(--text-primary);
font-size: 14px;
font-family: inherit;
resize: none;
outline: none;
transition: all 0.15s ease;
}
textarea:focus {
border-color: var(--accent-muted);
box-shadow: 0 0 0 3px rgba(88, 166, 255, 0.15);
}
textarea::placeholder {
color: var(--text-muted);
}
.add-context-btn {
position: absolute;
right: 12px;
bottom: 12px;
width: 24px;
height: 24px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.15s ease;
}
.add-context-btn:hover {
background: var(--border);
color: var(--text-primary);
}
.send-btn {
width: 48px;
height: 48px;
background: linear-gradient(135deg, var(--accent-muted), var(--accent));
border: none;
border-radius: 12px;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
transition: all 0.15s ease;
box-shadow: 0 4px 12px rgba(88, 166, 255, 0.3);
}
.send-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(88, 166, 255, 0.4);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.loading {
display: none;
align-items: center;
gap: 8px;
color: var(--text-secondary);
font-size: 13px;
padding: 8px 0;
}
.loading.active {
display: flex;
}
.loading-dots {
display: flex;
gap: 4px;
}
.loading-dot {
width: 6px;
height: 6px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.4s infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.tool-result {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-top: 12px;
font-size: 13px;
}
.tool-result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: var(--text-secondary);
}
.tool-result-status {
width: 8px;
height: 8px;
border-radius: 50%;
}
.tool-result-status.success { background: var(--success); }
.tool-result-status.error { background: var(--danger); }
.tool-result code {
color: var(--accent);
background: var(--bg-secondary);
padding: 2px 6px;
border-radius: 4px;
}
.code-block {
background: var(--bg-primary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px;
margin-top: 8px;
overflow-x: auto;
}
.code-block pre {
font-family: "SF Mono", Monaco, "Cascadia Code", monospace;
font-size: 12px;
line-height: 1.5;
color: var(--text-primary);
}
</style>
</head>
<body>
<div class="header">
<div class="header-title">
<div class="header-icon">✦</div>
<h1>Product OS Chat</h1>
</div>
<div class="header-actions">
<button class="header-btn" onclick="clearChat()">Clear</button>
</div>
</div>
<div class="messages" id="messages">
${messagesHtml || `
<div class="empty-state">
<div class="empty-icon">✦</div>
<h2>Welcome to Product OS</h2>
<p>I can help you deploy services, analyze metrics, generate marketing content, and write code—all in one place.</p>
<div class="suggestions">
<button class="suggestion" onclick="sendSuggestion('Deploy my service to staging')">Deploy to staging</button>
<button class="suggestion" onclick="sendSuggestion('Show me funnel analytics')">Funnel analytics</button>
<button class="suggestion" onclick="sendSuggestion('Generate launch posts for X and LinkedIn')">Marketing posts</button>
<button class="suggestion" onclick="sendSuggestion('What drives my conversion rate?')">Conversion drivers</button>
</div>
</div>
`}
</div>
<div class="loading" id="loading">
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<span>Product OS is thinking...</span>
</div>
<div class="input-area">
<div class="context-badge" id="context-badge">
<span>📎 Context:</span>
<code id="context-path"></code>
<span class="context-close" onclick="clearContext()">✕</span>
</div>
<div class="input-wrapper">
<div class="input-container">
<textarea
id="input"
placeholder="Ask me to deploy, analyze, or generate..."
rows="1"
onkeydown="handleKeydown(event)"
oninput="autoResize(this)"
></textarea>
<button class="add-context-btn" onclick="addContext()" title="Add code context">@</button>
</div>
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
</div>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
let currentContext = null;
function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
// Send to extension
vscode.postMessage({ command: 'send', text, context: currentContext });
// Clear input and context
input.value = '';
input.style.height = 'auto';
clearContext();
// Add user message to UI immediately
addMessageToUI('user', text);
}
function sendSuggestion(text) {
document.getElementById('input').value = text;
sendMessage();
}
function handleKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 200) + 'px';
}
function addContext() {
vscode.postMessage({ command: 'addContext' });
}
function clearContext() {
currentContext = null;
document.getElementById('context-badge').classList.remove('active');
}
function clearChat() {
vscode.postMessage({ command: 'clear' });
}
function addMessageToUI(role, content) {
const messages = document.getElementById('messages');
const isEmpty = messages.querySelector('.empty-state');
if (isEmpty) isEmpty.remove();
const isUser = role === 'user';
const div = document.createElement('div');
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
div.innerHTML = \`
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
<div class="content">\${escapeHtml(content)}</div>
\`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/\\n/g, '<br>');
}
function formatMessage(text) {
// Simple markdown-like formatting
return text
.replace(/\\*\\*(.+?)\\*\\*/g, '<strong>$1</strong>')
.replace(/\`(.+?)\`/g, '<code>$1</code>')
.replace(/\\n/g, '<br>');
}
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'loading':
document.getElementById('loading').classList.toggle('active', message.loading);
document.getElementById('send-btn').disabled = message.loading;
break;
case 'response':
// Add AI response to UI
if (message.message) {
addMessageToUI('assistant', message.message);
}
// Show tool results if any
if (message.runs && message.runs.length > 0) {
const messages = document.getElementById('messages');
message.runs.forEach(run => {
const resultDiv = document.createElement('div');
resultDiv.className = 'tool-result';
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
resultDiv.innerHTML = \`
<div class="tool-result-header">
<div class="tool-result-status \${statusClass}"></div>
<span>Executed <code>\${run.tool}</code></span>
</div>
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
\`;
messages.appendChild(resultDiv);
});
messages.scrollTop = messages.scrollHeight;
}
break;
case 'error':
addMessageToUI('assistant', '❌ Error: ' + message.error);
break;
case 'contextAdded':
currentContext = message.context;
document.getElementById('context-badge').classList.add('active');
document.getElementById('context-path').textContent = message.context.path;
break;
}
});
// Focus input on load
document.getElementById('input').focus();
</script>
</body>
</html>`;
}
}
function getNonce() {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, "<br>");
}

View File

@@ -0,0 +1,688 @@
import * as vscode from "vscode";
import { chatWithAI, ChatMessage, ChatResponse } from "./api";
/**
* Sidebar Chat View Provider
* Embedded chat experience in the Product OS sidebar
*/
export class ChatViewProvider implements vscode.WebviewViewProvider {
public static readonly viewType = "productos.chat";
private _view?: vscode.WebviewView;
private _messages: ChatMessage[] = [];
constructor(private readonly _extensionUri: vscode.Uri) {}
public resolveWebviewView(
webviewView: vscode.WebviewView,
_context: vscode.WebviewViewResolveContext,
_token: vscode.CancellationToken
) {
this._view = webviewView;
webviewView.webview.options = {
enableScripts: true,
localResourceRoots: [this._extensionUri]
};
webviewView.webview.html = this._getHtmlForWebview(webviewView.webview);
webviewView.webview.onDidReceiveMessage(async (message) => {
switch (message.command) {
case "send":
await this._handleChat(message.text);
return;
case "addContext":
await this._handleAddContext();
return;
case "clear":
this._messages = [];
this._updateView();
return;
}
});
}
private async _handleChat(text: string) {
if (!this._view) return;
// Add user message to internal history (webview already shows it)
this._messages.push({ role: "user", content: text });
// DON'T call _updateView() - it would reset the webview and kill the JS state
// Show loading
this._view.webview.postMessage({ type: "loading", loading: true });
try {
// Get editor context
const context = this._getEditorContext();
console.log("[Product OS Chat] Sending to API:", text);
// Call AI
const response = await chatWithAI(this._messages, context);
console.log("[Product OS Chat] Response:", response);
// Add assistant response to history
this._messages.push({ role: "assistant", content: response.message || "" });
// Send to webview
this._view.webview.postMessage({
type: "response",
message: response.message,
toolCalls: response.toolCalls,
runs: response.runs
});
} catch (error: any) {
console.error("[Product OS Chat] Error:", error);
this._view.webview.postMessage({
type: "error",
error: error.message || "Unknown error"
});
} finally {
this._view.webview.postMessage({ type: "loading", loading: false });
}
}
private async _handleAddContext() {
if (!this._view) return;
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showWarningMessage("No active editor");
return;
}
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
this._view.webview.postMessage({
type: "contextAdded",
context: {
type: "selection",
path: filePath,
startLine: selection.start.line + 1,
text: selectedText
}
});
} else {
const filePath = vscode.workspace.asRelativePath(editor.document.uri);
const content = editor.document.getText();
this._view.webview.postMessage({
type: "contextAdded",
context: {
type: "file",
path: filePath,
text: content.substring(0, 5000)
}
});
}
}
private _getEditorContext(): any {
const editor = vscode.window.activeTextEditor;
if (!editor) return undefined;
const selection = editor.selection;
const selectedText = editor.document.getText(selection);
if (selectedText) {
return {
selection: {
path: vscode.workspace.asRelativePath(editor.document.uri),
text: selectedText,
startLine: selection.start.line + 1
}
};
}
return undefined;
}
private _updateView() {
if (this._view) {
this._view.webview.html = this._getHtmlForWebview(this._view.webview);
}
}
private _getHtmlForWebview(webview: vscode.Webview) {
const nonce = getNonce();
const messagesHtml = this._messages
.map((m) => {
const isUser = m.role === "user";
const avatarClass = isUser ? "user-avatar" : "ai-avatar";
const messageClass = isUser ? "user-message" : "ai-message";
const avatar = isUser ? "U" : "✦";
return `
<div class="message ${messageClass}">
<div class="avatar ${avatarClass}">${avatar}</div>
<div class="content">${escapeHtml(m.content)}</div>
</div>
`;
})
.join("");
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'nonce-${nonce}';">
<title>Product OS Chat</title>
<style>
:root {
--bg-primary: var(--vscode-editor-background);
--bg-secondary: var(--vscode-sideBar-background);
--bg-tertiary: var(--vscode-input-background);
--accent: var(--vscode-focusBorder);
--text-primary: var(--vscode-foreground);
--text-secondary: var(--vscode-descriptionForeground);
--border: var(--vscode-panel-border);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: var(--vscode-font-family);
font-size: var(--vscode-font-size);
background: var(--bg-secondary);
color: var(--text-primary);
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
}
.message {
display: flex;
gap: 8px;
animation: fadeIn 0.15s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
.avatar {
width: 24px;
height: 24px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.user-avatar {
background: var(--vscode-badge-background);
color: var(--vscode-badge-foreground);
}
.ai-avatar {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.content {
flex: 1;
padding: 8px 12px;
border-radius: 8px;
line-height: 1.5;
font-size: 12px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.empty-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 20px;
}
.empty-icon {
width: 40px;
height: 40px;
background: var(--vscode-button-background);
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
margin-bottom: 12px;
}
.empty-state h2 {
font-size: 14px;
font-weight: 600;
margin-bottom: 6px;
}
.empty-state p {
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
}
.suggestions {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 16px;
width: 100%;
}
.suggestion {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: all 0.1s ease;
}
.suggestion:hover {
background: var(--vscode-list-hoverBackground);
color: var(--text-primary);
}
.input-area {
padding: 12px;
border-top: 1px solid var(--border);
}
.context-badge {
display: none;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 8px;
font-size: 10px;
color: var(--text-secondary);
}
.context-badge.active {
display: flex;
}
.context-badge code {
color: var(--accent);
}
.context-close {
margin-left: auto;
cursor: pointer;
opacity: 0.6;
}
.context-close:hover {
opacity: 1;
}
.input-wrapper {
display: flex;
gap: 8px;
align-items: flex-end;
}
.input-container {
flex: 1;
position: relative;
}
textarea {
width: 100%;
min-height: 36px;
max-height: 120px;
padding: 8px 32px 8px 10px;
background: var(--vscode-input-background);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text-primary);
font-size: 12px;
font-family: inherit;
resize: none;
outline: none;
}
textarea:focus {
border-color: var(--accent);
}
textarea::placeholder {
color: var(--text-secondary);
}
.add-context-btn {
position: absolute;
right: 8px;
bottom: 8px;
width: 20px;
height: 20px;
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
border-radius: 4px;
}
.add-context-btn:hover {
background: var(--vscode-list-hoverBackground);
color: var(--text-primary);
}
.send-btn {
width: 36px;
height: 36px;
background: var(--vscode-button-background);
border: none;
border-radius: 6px;
color: var(--vscode-button-foreground);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
}
.send-btn:hover {
background: var(--vscode-button-hoverBackground);
}
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.loading {
display: none;
align-items: center;
gap: 6px;
color: var(--text-secondary);
font-size: 11px;
padding: 6px 0;
}
.loading.active {
display: flex;
}
.loading-dots {
display: flex;
gap: 3px;
}
.loading-dot {
width: 4px;
height: 4px;
background: var(--accent);
border-radius: 50%;
animation: pulse 1.4s infinite;
}
.loading-dot:nth-child(2) { animation-delay: 0.2s; }
.loading-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes pulse {
0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
40% { opacity: 1; transform: scale(1); }
}
.tool-result {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px;
margin-top: 8px;
font-size: 11px;
}
.tool-result-header {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
color: var(--text-secondary);
}
.tool-result-status {
width: 6px;
height: 6px;
border-radius: 50%;
}
.tool-result-status.success { background: var(--vscode-testing-iconPassed); }
.tool-result-status.error { background: var(--vscode-testing-iconFailed); }
.code-block {
background: var(--vscode-editor-background);
border: 1px solid var(--border);
border-radius: 4px;
padding: 8px;
margin-top: 6px;
overflow-x: auto;
}
.code-block pre {
font-family: var(--vscode-editor-font-family);
font-size: 10px;
line-height: 1.4;
}
</style>
</head>
<body>
<div class="messages" id="messages">
${
messagesHtml ||
`
<div class="empty-state">
<div class="empty-icon">✦</div>
<h2>Product OS Chat</h2>
<p>Deploy, analyze, and create with AI.</p>
<div class="suggestions">
<button class="suggestion" onclick="sendSuggestion('Deploy to staging')">🚀 Deploy to staging</button>
<button class="suggestion" onclick="sendSuggestion('Show funnel analytics')">📊 Funnel analytics</button>
<button class="suggestion" onclick="sendSuggestion('Generate marketing posts')">📣 Marketing posts</button>
</div>
</div>
`
}
</div>
<div class="loading" id="loading">
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>
<span>Thinking...</span>
</div>
<div class="input-area">
<div class="context-badge" id="context-badge">
<span>📎</span>
<code id="context-path"></code>
<span class="context-close" onclick="clearContext()">✕</span>
</div>
<div class="input-wrapper">
<div class="input-container">
<textarea
id="input"
placeholder="Ask anything..."
rows="1"
onkeydown="handleKeydown(event)"
oninput="autoResize(this)"
></textarea>
<button class="add-context-btn" onclick="addContext()" title="Add context">@</button>
</div>
<button class="send-btn" id="send-btn" onclick="sendMessage()">→</button>
</div>
</div>
<script nonce="${nonce}">
const vscode = acquireVsCodeApi();
let currentContext = null;
function sendMessage() {
const input = document.getElementById('input');
const text = input.value.trim();
if (!text) return;
vscode.postMessage({ command: 'send', text, context: currentContext });
input.value = '';
input.style.height = 'auto';
clearContext();
addMessageToUI('user', text);
}
function sendSuggestion(text) {
document.getElementById('input').value = text;
sendMessage();
}
function handleKeydown(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
function autoResize(textarea) {
textarea.style.height = 'auto';
textarea.style.height = Math.min(textarea.scrollHeight, 120) + 'px';
}
function addContext() {
vscode.postMessage({ command: 'addContext' });
}
function clearContext() {
currentContext = null;
document.getElementById('context-badge').classList.remove('active');
}
function addMessageToUI(role, content) {
const messages = document.getElementById('messages');
const isEmpty = messages.querySelector('.empty-state');
if (isEmpty) isEmpty.remove();
const isUser = role === 'user';
const div = document.createElement('div');
div.className = 'message ' + (isUser ? 'user-message' : 'ai-message');
div.innerHTML = \`
<div class="avatar \${isUser ? 'user-avatar' : 'ai-avatar'}">\${isUser ? 'U' : '✦'}</div>
<div class="content">\${escapeHtml(content)}</div>
\`;
messages.appendChild(div);
messages.scrollTop = messages.scrollHeight;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML.replace(/\\n/g, '<br>');
}
window.addEventListener('message', event => {
const message = event.data;
switch (message.type) {
case 'loading':
document.getElementById('loading').classList.toggle('active', message.loading);
document.getElementById('send-btn').disabled = message.loading;
break;
case 'response':
if (message.message) {
addMessageToUI('assistant', message.message);
}
if (message.runs && message.runs.length > 0) {
const messages = document.getElementById('messages');
message.runs.forEach(run => {
const resultDiv = document.createElement('div');
resultDiv.className = 'tool-result';
const statusClass = run.status === 'succeeded' ? 'success' : 'error';
resultDiv.innerHTML = \`
<div class="tool-result-header">
<div class="tool-result-status \${statusClass}"></div>
<span>\${run.tool}</span>
</div>
\${run.output ? \`<div class="code-block"><pre>\${JSON.stringify(run.output, null, 2)}</pre></div>\` : ''}
\`;
messages.appendChild(resultDiv);
});
messages.scrollTop = messages.scrollHeight;
}
break;
case 'error':
addMessageToUI('assistant', '❌ ' + message.error);
break;
case 'contextAdded':
currentContext = message.context;
document.getElementById('context-badge').classList.add('active');
document.getElementById('context-path').textContent = message.context.path;
break;
}
});
document.getElementById('input').focus();
</script>
</body>
</html>`;
}
}
function getNonce() {
let text = "";
const possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
for (let i = 0; i < 32; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;")
.replace(/\n/g, "<br>");
}

View File

@@ -0,0 +1,182 @@
import * as vscode from "vscode";
import { listTools, invokeTool, getRun, addRecentRun, checkConnection } from "./api";
import { ToolsTreeProvider, ToolItem } from "./toolsTreeView";
import { RunsTreeProvider, RunItem } from "./runsTreeView";
import { createStatusBar, updateConnectionStatus, dispose as disposeStatusBar } from "./statusBar";
import { InvokePanel } from "./invokePanel";
import { ChatPanel } from "./chatPanel";
import { ChatViewProvider } from "./chatViewProvider";
import { showJson, openRun, showRunDocument } from "./ui";
export function activate(context: vscode.ExtensionContext) {
console.log("Product OS extension activated");
// Create tree providers
const toolsProvider = new ToolsTreeProvider();
const runsProvider = new RunsTreeProvider();
// Register sidebar Chat view provider
const chatViewProvider = new ChatViewProvider(context.extensionUri);
context.subscriptions.push(
vscode.window.registerWebviewViewProvider(ChatViewProvider.viewType, chatViewProvider)
);
// Register Chat panel command (opens in editor area)
context.subscriptions.push(
vscode.commands.registerCommand("productos.chat", () => {
ChatPanel.createOrShow(context.extensionUri);
})
);
// Register tree views
vscode.window.registerTreeDataProvider("productos.tools", toolsProvider);
vscode.window.registerTreeDataProvider("productos.runs", runsProvider);
// Create status bar
createStatusBar(context);
// Load tools on startup
toolsProvider.loadTools();
// === COMMANDS ===
// Configure backend URL
context.subscriptions.push(
vscode.commands.registerCommand("productos.configure", async () => {
const currentUrl = vscode.workspace.getConfiguration("productos").get("backendUrl");
const backendUrl = await vscode.window.showInputBox({
prompt: "Control Plane backend URL",
value: currentUrl as string,
placeHolder: "http://localhost:8080"
});
if (!backendUrl) return;
await vscode.workspace.getConfiguration("productos").update(
"backendUrl",
backendUrl,
vscode.ConfigurationTarget.Global
);
vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`);
updateConnectionStatus();
toolsProvider.loadTools();
})
);
// Refresh tools
context.subscriptions.push(
vscode.commands.registerCommand("productos.refresh", async () => {
await toolsProvider.loadTools();
runsProvider.refresh();
updateConnectionStatus();
vscode.window.showInformationMessage("Product OS refreshed");
})
);
// List tools (JSON view)
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.list", async () => {
try {
const tools = await listTools();
await showJson("Tools", tools);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed to list tools: ${e.message}`);
}
})
);
// Invoke tool (quick pick)
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.invoke", async () => {
try {
const tools = await listTools();
if (tools.length === 0) {
vscode.window.showWarningMessage("No tools available");
return;
}
const pick = await vscode.window.showQuickPick(
tools.map(t => ({
label: t.name,
description: `[${t.risk}] ${t.description}`,
tool: t
})),
{ placeHolder: "Select a tool to invoke" }
);
if (!pick) return;
// Open invoke panel
InvokePanel.createOrShow(
context.extensionUri,
pick.tool,
() => runsProvider.refresh()
);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed: ${e.message}`);
}
})
);
// Invoke from tree view
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.invokeFromTree", async (item: ToolItem) => {
if (!item?.tool) {
// No item passed, show quick pick
vscode.commands.executeCommand("productos.tools.invoke");
return;
}
InvokePanel.createOrShow(
context.extensionUri,
item.tool,
() => runsProvider.refresh()
);
})
);
// Open run by ID
context.subscriptions.push(
vscode.commands.registerCommand("productos.runs.open", async () => {
const runId = await vscode.window.showInputBox({
prompt: "Enter Run ID",
placeHolder: "run_20240101..."
});
if (!runId) return;
try {
await openRun(runId);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed to open run: ${e.message}`);
}
})
);
// Open run from tree view
context.subscriptions.push(
vscode.commands.registerCommand("productos.runs.openFromTree", async (item: RunItem) => {
if (!item?.run) return;
try {
const fullRun = await getRun(item.run.run_id);
await showRunDocument(fullRun);
} catch (e: any) {
vscode.window.showErrorMessage(`Failed to open run: ${e.message}`);
}
})
);
// Watch for config changes
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration(e => {
if (e.affectsConfiguration("productos")) {
updateConnectionStatus();
toolsProvider.loadTools();
}
})
);
}
export function deactivate() {
disposeStatusBar();
}

View File

@@ -0,0 +1,373 @@
import * as vscode from "vscode";
import { Tool, invokeTool, getRun, addRecentRun } from "./api";
export class InvokePanel {
public static currentPanel: InvokePanel | undefined;
private readonly _panel: vscode.WebviewPanel;
private readonly _extensionUri: vscode.Uri;
private _tool: Tool;
private _disposables: vscode.Disposable[] = [];
private _onRunComplete: () => void;
public static createOrShow(
extensionUri: vscode.Uri,
tool: Tool,
onRunComplete: () => void
) {
const column = vscode.window.activeTextEditor?.viewColumn ?? vscode.ViewColumn.One;
if (InvokePanel.currentPanel) {
InvokePanel.currentPanel._tool = tool;
InvokePanel.currentPanel._onRunComplete = onRunComplete;
InvokePanel.currentPanel._update();
InvokePanel.currentPanel._panel.reveal(column);
return;
}
const panel = vscode.window.createWebviewPanel(
"productosInvoke",
`Invoke: ${tool.name}`,
column,
{
enableScripts: true,
retainContextWhenHidden: true
}
);
InvokePanel.currentPanel = new InvokePanel(panel, extensionUri, tool, onRunComplete);
}
private constructor(
panel: vscode.WebviewPanel,
extensionUri: vscode.Uri,
tool: Tool,
onRunComplete: () => void
) {
this._panel = panel;
this._extensionUri = extensionUri;
this._tool = tool;
this._onRunComplete = onRunComplete;
this._update();
this._panel.onDidDispose(() => this.dispose(), null, this._disposables);
this._panel.webview.onDidReceiveMessage(
async (message) => {
switch (message.command) {
case "invoke":
await this._handleInvoke(message.input, message.dryRun);
break;
case "close":
this._panel.dispose();
break;
}
},
null,
this._disposables
);
}
private async _handleInvoke(inputText: string, dryRun: boolean) {
try {
const input = JSON.parse(inputText);
this._panel.webview.postMessage({ command: "invoking" });
const result = await invokeTool(this._tool.name, input, dryRun);
// Fetch full run details
const fullRun = await getRun(result.run_id);
addRecentRun(fullRun);
this._onRunComplete();
this._panel.webview.postMessage({
command: "result",
run: fullRun
});
} catch (e: any) {
this._panel.webview.postMessage({
command: "error",
message: e.message
});
}
}
private _update() {
this._panel.title = `Invoke: ${this._tool.name}`;
this._panel.webview.html = this._getHtml();
}
private _getHtml(): string {
const tool = this._tool;
const schemaStr = JSON.stringify(tool.inputSchema, null, 2);
const defaultInput = this._generateDefaultInput(tool.inputSchema);
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Invoke ${tool.name}</title>
<style>
* { box-sizing: border-box; }
body {
font-family: var(--vscode-font-family);
padding: 20px;
color: var(--vscode-foreground);
background: var(--vscode-editor-background);
}
h1 {
font-size: 1.5em;
margin: 0 0 5px 0;
display: flex;
align-items: center;
gap: 10px;
}
.risk {
font-size: 0.7em;
padding: 3px 8px;
border-radius: 4px;
font-weight: normal;
}
.risk-low { background: #2ea043; color: white; }
.risk-medium { background: #d29922; color: black; }
.risk-high { background: #f85149; color: white; }
.description {
color: var(--vscode-descriptionForeground);
margin-bottom: 20px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
textarea {
width: 100%;
min-height: 200px;
padding: 10px;
font-family: var(--vscode-editor-font-family);
font-size: var(--vscode-editor-font-size);
background: var(--vscode-input-background);
color: var(--vscode-input-foreground);
border: 1px solid var(--vscode-input-border);
border-radius: 4px;
resize: vertical;
}
textarea:focus {
outline: 1px solid var(--vscode-focusBorder);
}
.buttons {
margin-top: 15px;
display: flex;
gap: 10px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
}
.btn-primary {
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.btn-primary:hover {
background: var(--vscode-button-hoverBackground);
}
.btn-secondary {
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: normal;
margin-left: auto;
}
.schema {
margin-top: 20px;
padding: 10px;
background: var(--vscode-textBlockQuote-background);
border-radius: 4px;
font-size: 0.9em;
}
.schema summary {
cursor: pointer;
font-weight: bold;
}
.schema pre {
margin: 10px 0 0 0;
white-space: pre-wrap;
}
.result {
margin-top: 20px;
padding: 15px;
border-radius: 4px;
}
.result-success {
background: var(--vscode-inputValidation-infoBackground);
border: 1px solid var(--vscode-inputValidation-infoBorder);
}
.result-error {
background: var(--vscode-inputValidation-errorBackground);
border: 1px solid var(--vscode-inputValidation-errorBorder);
}
.result h3 { margin: 0 0 10px 0; }
.result pre {
background: var(--vscode-editor-background);
padding: 10px;
border-radius: 4px;
overflow-x: auto;
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid var(--vscode-foreground);
border-top-color: transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.hidden { display: none; }
</style>
</head>
<body>
<h1>
${tool.name}
<span class="risk risk-${tool.risk}">${tool.risk} risk</span>
</h1>
<p class="description">${tool.description}</p>
<label for="input">Input JSON</label>
<textarea id="input">${defaultInput}</textarea>
<div class="buttons">
<button class="btn-primary" id="invokeBtn" onclick="invoke(false)">
<span id="invokeText">▶ Invoke</span>
<span id="invokingText" class="hidden"><span class="spinner"></span>Invoking...</span>
</button>
<button class="btn-secondary" onclick="invoke(true)">🧪 Dry Run</button>
<label class="checkbox-label">
<input type="checkbox" id="autoFormat" checked>
Auto-format JSON
</label>
</div>
<div id="result"></div>
<details class="schema">
<summary>Input Schema</summary>
<pre>${schemaStr}</pre>
</details>
<script>
const vscode = acquireVsCodeApi();
const inputEl = document.getElementById('input');
const invokeBtn = document.getElementById('invokeBtn');
const invokeText = document.getElementById('invokeText');
const invokingText = document.getElementById('invokingText');
const resultEl = document.getElementById('result');
inputEl.addEventListener('blur', () => {
if (document.getElementById('autoFormat').checked) {
try {
const parsed = JSON.parse(inputEl.value);
inputEl.value = JSON.stringify(parsed, null, 2);
} catch {}
}
});
function invoke(dryRun) {
vscode.postMessage({
command: 'invoke',
input: inputEl.value,
dryRun
});
}
window.addEventListener('message', event => {
const message = event.data;
switch (message.command) {
case 'invoking':
invokeBtn.disabled = true;
invokeText.classList.add('hidden');
invokingText.classList.remove('hidden');
resultEl.innerHTML = '';
break;
case 'result':
invokeBtn.disabled = false;
invokeText.classList.remove('hidden');
invokingText.classList.add('hidden');
const run = message.run;
const statusEmoji = run.status === 'succeeded' ? '✅' : run.status === 'failed' ? '❌' : '🔄';
resultEl.innerHTML = \`
<div class="result result-success">
<h3>\${statusEmoji} Run \${run.status}</h3>
<p><strong>Run ID:</strong> \${run.run_id}</p>
<h4>Output:</h4>
<pre>\${JSON.stringify(run.output || run.error || {}, null, 2)}</pre>
</div>
\`;
break;
case 'error':
invokeBtn.disabled = false;
invokeText.classList.remove('hidden');
invokingText.classList.add('hidden');
resultEl.innerHTML = \`
<div class="result result-error">
<h3>❌ Error</h3>
<pre>\${message.message}</pre>
</div>
\`;
break;
}
});
</script>
</body>
</html>`;
}
private _generateDefaultInput(schema: any): string {
if (!schema || schema.type !== "object") return "{}";
const obj: any = {};
const props = schema.properties || {};
const required = schema.required || [];
for (const key of required) {
const prop = props[key];
if (!prop) continue;
if (prop.type === "string") {
obj[key] = prop.enum ? prop.enum[0] : "";
} else if (prop.type === "integer" || prop.type === "number") {
obj[key] = prop.minimum ?? 0;
} else if (prop.type === "boolean") {
obj[key] = false;
} else if (prop.type === "array") {
obj[key] = [];
} else if (prop.type === "object") {
obj[key] = {};
}
}
return JSON.stringify(obj, null, 2);
}
public dispose() {
InvokePanel.currentPanel = undefined;
this._panel.dispose();
while (this._disposables.length) {
const d = this._disposables.pop();
if (d) d.dispose();
}
}
}

View File

@@ -0,0 +1,54 @@
import * as vscode from "vscode";
import { Run, getRecentRuns } from "./api";
export class RunsTreeProvider implements vscode.TreeDataProvider<RunItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<RunItem | undefined>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
getTreeItem(element: RunItem): vscode.TreeItem {
return element;
}
getChildren(element?: RunItem): RunItem[] {
if (element) return [];
return getRecentRuns().map(run => new RunItem(run));
}
}
export class RunItem extends vscode.TreeItem {
constructor(public readonly run: Run) {
super(run.tool, vscode.TreeItemCollapsibleState.None);
const statusIcon = run.status === "succeeded" ? "✅" :
run.status === "failed" ? "❌" :
run.status === "running" ? "🔄" : "⏳";
this.description = `${statusIcon} ${run.status}`;
const time = new Date(run.created_at).toLocaleTimeString();
this.tooltip = new vscode.MarkdownString(
`**${run.tool}**\n\n` +
`Status: ${run.status}\n\n` +
`Run ID: \`${run.run_id}\`\n\n` +
`Time: ${time}`
);
this.contextValue = "run";
// Icon based on status
const iconId = run.status === "succeeded" ? "pass" :
run.status === "failed" ? "error" :
run.status === "running" ? "sync~spin" : "clock";
this.iconPath = new vscode.ThemeIcon(iconId);
this.command = {
command: "productos.runs.openFromTree",
title: "View Run",
arguments: [this]
};
}
}

View File

@@ -0,0 +1,41 @@
import * as vscode from "vscode";
import { checkConnection, getBackendUrl } from "./api";
let statusBarItem: vscode.StatusBarItem;
let checkInterval: NodeJS.Timeout | undefined;
export function createStatusBar(context: vscode.ExtensionContext): vscode.StatusBarItem {
statusBarItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
statusBarItem.command = "productos.configure";
statusBarItem.text = "$(cloud) Product OS";
statusBarItem.tooltip = "Click to configure Product OS";
statusBarItem.show();
context.subscriptions.push(statusBarItem);
// Check connection periodically
updateConnectionStatus();
checkInterval = setInterval(updateConnectionStatus, 30000);
return statusBarItem;
}
export async function updateConnectionStatus(): Promise<void> {
const connected = await checkConnection();
if (connected) {
statusBarItem.text = "$(cloud) Product OS";
statusBarItem.backgroundColor = undefined;
statusBarItem.tooltip = `Connected to ${getBackendUrl()}`;
} else {
statusBarItem.text = "$(cloud-offline) Product OS";
statusBarItem.backgroundColor = new vscode.ThemeColor("statusBarItem.errorBackground");
statusBarItem.tooltip = `Disconnected - Click to configure`;
}
}
export function dispose(): void {
if (checkInterval) {
clearInterval(checkInterval);
}
}

View File

@@ -0,0 +1,63 @@
import * as vscode from "vscode";
import { Tool, listTools } from "./api";
export class ToolsTreeProvider implements vscode.TreeDataProvider<ToolItem> {
private _onDidChangeTreeData = new vscode.EventEmitter<ToolItem | undefined>();
readonly onDidChangeTreeData = this._onDidChangeTreeData.event;
private tools: Tool[] = [];
refresh(): void {
this._onDidChangeTreeData.fire(undefined);
}
async loadTools(): Promise<void> {
try {
this.tools = await listTools();
this.refresh();
} catch (e) {
this.tools = [];
this.refresh();
}
}
getTreeItem(element: ToolItem): vscode.TreeItem {
return element;
}
async getChildren(element?: ToolItem): Promise<ToolItem[]> {
if (element) return [];
if (this.tools.length === 0) {
await this.loadTools();
}
return this.tools.map(tool => new ToolItem(tool));
}
}
export class ToolItem extends vscode.TreeItem {
constructor(public readonly tool: Tool) {
super(tool.name, vscode.TreeItemCollapsibleState.None);
this.description = tool.description;
this.tooltip = new vscode.MarkdownString(
`**${tool.name}**\n\n${tool.description}\n\n` +
`Risk: \`${tool.risk}\`\n\n` +
`Executor: \`${tool.executor.url}${tool.executor.path}\``
);
this.contextValue = "tool";
// Icon based on risk level
const iconColor = tool.risk === "high" ? "red" : tool.risk === "medium" ? "orange" : "green";
this.iconPath = new vscode.ThemeIcon("symbol-method", new vscode.ThemeColor(`charts.${iconColor}`));
// Click to invoke
this.command = {
command: "productos.tools.invokeFromTree",
title: "Invoke Tool",
arguments: [this]
};
}
}

View File

@@ -0,0 +1,40 @@
import * as vscode from "vscode";
import { getRun, Run } from "./api";
export async function showJson(title: string, obj: any) {
const doc = await vscode.workspace.openTextDocument({
content: JSON.stringify(obj, null, 2),
language: "json"
});
await vscode.window.showTextDocument(doc, { preview: false });
vscode.window.setStatusBarMessage(title, 3000);
}
export async function openRun(runId: string) {
const run = await getRun(runId);
await showRunDocument(run);
}
export async function showRunDocument(run: Run) {
const statusEmoji = run.status === "succeeded" ? "✅" :
run.status === "failed" ? "❌" :
run.status === "running" ? "🔄" : "⏳";
const content = `// Run: ${run.run_id}
// Tool: ${run.tool}
// Status: ${statusEmoji} ${run.status}
// Created: ${new Date(run.created_at).toLocaleString()}
// === INPUT ===
${JSON.stringify(run.input, null, 2)}
// === OUTPUT ===
${JSON.stringify(run.output ?? run.error ?? null, null, 2)}
`;
const doc = await vscode.workspace.openTextDocument({
content,
language: "jsonc"
});
await vscode.window.showTextDocument(doc, { preview: false });
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}

View File

@@ -0,0 +1,398 @@
version: 1
tools:
# ----------------------------
# CODE / DEPLOYMENT
# ----------------------------
cloudrun.deploy_service:
description: Build and deploy a Cloud Run service using Cloud Build. Returns the service URL and deployed revision.
risk: medium
executor:
kind: http
url: https://deploy-executor-REPLACE.a.run.app
path: /execute/cloudrun/deploy
inputSchema:
type: object
additionalProperties: false
required: [service_name, repo, ref, env, region]
properties:
service_name:
type: string
minLength: 1
description: Cloud Run service name.
repo:
type: string
minLength: 1
description: Git repo URL (HTTPS).
ref:
type: string
minLength: 1
description: Git ref (branch/tag/SHA).
env:
type: string
enum: [dev, staging, prod]
region:
type: string
minLength: 1
description: GCP region for the Cloud Run service (e.g., us-central1).
outputSchema:
type: object
properties:
service_url:
type: string
revision:
type: string
cloudrun.get_service_status:
description: Fetch Cloud Run service status including latest revision and URL.
risk: low
executor:
kind: http
url: https://deploy-executor-REPLACE.a.run.app
path: /execute/cloudrun/status
inputSchema:
type: object
additionalProperties: false
required: [service_name, region]
properties:
service_name:
type: string
minLength: 1
region:
type: string
minLength: 1
outputSchema:
type: object
properties:
service_name:
type: string
region:
type: string
service_url:
type: string
latest_ready_revision:
type: string
status:
type: string
enum: [ready, deploying, error, unknown]
logs.tail:
description: Tail recent logs for a Cloud Run service or for a specific run_id.
risk: low
executor:
kind: http
url: https://observability-executor-REPLACE.a.run.app
path: /execute/logs/tail
inputSchema:
type: object
additionalProperties: false
required: [scope, limit]
properties:
scope:
type: string
enum: [service, run]
service_name:
type: string
region:
type: string
run_id:
type: string
limit:
type: integer
minimum: 1
maximum: 2000
default: 200
outputSchema:
type: object
properties:
lines:
type: array
items:
type: object
properties:
timestamp:
type: string
severity:
type: string
text:
type: string
# ----------------------------
# COMPANY BRAIN (BRAND + STYLE)
# ----------------------------
brand.get_profile:
description: Retrieve the tenant's brand profile (voice, tone, positioning, compliance constraints).
risk: low
executor:
kind: http
url: https://firestore-executor-REPLACE.a.run.app
path: /execute/brand/get_profile
inputSchema:
type: object
additionalProperties: false
required: [profile_id]
properties:
profile_id:
type: string
minLength: 1
description: Brand profile identifier (e.g., "default").
outputSchema:
type: object
properties:
profile_id:
type: string
brand:
type: object
brand.update_profile:
description: Update the tenant's brand profile. Write operations should be validated and audited.
risk: medium
executor:
kind: http
url: https://firestore-executor-REPLACE.a.run.app
path: /execute/brand/update_profile
inputSchema:
type: object
additionalProperties: false
required: [profile_id, patch]
properties:
profile_id:
type: string
minLength: 1
patch:
type: object
description: Partial update object; executor must validate allowed fields.
outputSchema:
type: object
properties:
ok:
type: boolean
updated_at:
type: string
# ----------------------------
# ANALYTICS / CAUSATION
# ----------------------------
analytics.funnel_summary:
description: Return funnel metrics for a time window. Uses curated events in BigQuery.
risk: low
executor:
kind: http
url: https://analytics-executor-REPLACE.a.run.app
path: /execute/analytics/funnel_summary
inputSchema:
type: object
additionalProperties: false
required: [range_days, funnel]
properties:
range_days:
type: integer
minimum: 1
maximum: 365
funnel:
type: object
required: [name, steps]
properties:
name:
type: string
steps:
type: array
minItems: 2
items:
type: object
required: [event_name]
properties:
event_name:
type: string
outputSchema:
type: object
properties:
funnel_name:
type: string
range_days:
type: integer
steps:
type: array
analytics.top_drivers:
description: Identify top correlated drivers for a target metric/event.
risk: low
executor:
kind: http
url: https://analytics-executor-REPLACE.a.run.app
path: /execute/analytics/top_drivers
inputSchema:
type: object
additionalProperties: false
required: [range_days, target]
properties:
range_days:
type: integer
minimum: 1
maximum: 365
target:
type: object
required: [metric]
properties:
metric:
type: string
event_name:
type: string
outputSchema:
type: object
properties:
target:
type: object
range_days:
type: integer
drivers:
type: array
analytics.write_insight:
description: Persist an insight object (BigQuery table + Firestore pointer + GCS artifact).
risk: medium
executor:
kind: http
url: https://analytics-executor-REPLACE.a.run.app
path: /execute/analytics/write_insight
inputSchema:
type: object
additionalProperties: false
required: [insight]
properties:
insight:
type: object
required: [type, title, summary, severity, confidence, window, recommendations]
properties:
type:
type: string
enum: [funnel_drop, anomaly, driver, experiment_result, general]
title:
type: string
summary:
type: string
severity:
type: string
enum: [info, low, medium, high, critical]
confidence:
type: number
minimum: 0
maximum: 1
window:
type: object
recommendations:
type: array
outputSchema:
type: object
properties:
insight_id:
type: string
stored:
type: object
# ----------------------------
# MARKETING (GENERATION + PUBLISH)
# ----------------------------
marketing.generate_channel_posts:
description: Generate platform-specific social posts from a campaign brief + brand profile.
risk: low
executor:
kind: http
url: https://marketing-executor-REPLACE.a.run.app
path: /execute/marketing/generate_channel_posts
inputSchema:
type: object
additionalProperties: false
required: [brief, channels, brand_profile_id]
properties:
brand_profile_id:
type: string
brief:
type: object
required: [goal, product, audience, key_points]
properties:
goal:
type: string
product:
type: string
audience:
type: string
key_points:
type: array
items:
type: string
channels:
type: array
items:
type: string
enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit]
variations_per_channel:
type: integer
minimum: 1
maximum: 10
default: 3
outputSchema:
type: object
properties:
channels:
type: array
marketing.publish_missinglettr:
description: Publish or schedule a campaign via Missinglettr.
risk: medium
executor:
kind: http
url: https://marketing-executor-REPLACE.a.run.app
path: /execute/marketing/publish_missinglettr
inputSchema:
type: object
additionalProperties: false
required: [campaign, schedule]
properties:
campaign:
type: object
required: [name, posts]
properties:
name:
type: string
posts:
type: array
items:
type: object
required: [channel, text]
properties:
channel:
type: string
text:
type: string
media_urls:
type: array
items:
type: string
schedule:
type: object
required: [mode]
properties:
mode:
type: string
enum: [now, scheduled]
start_time:
type: string
timezone:
type: string
default: UTC
outputSchema:
type: object
properties:
provider:
type: string
campaign_id:
type: string
status:
type: string
enum: [queued, scheduled, published, failed]

View File

@@ -0,0 +1,41 @@
version: '3.8'
services:
# Firestore Emulator
firestore:
image: gcr.io/google.com/cloudsdktool/google-cloud-cli:emulators
command: gcloud emulators firestore start --host-port=0.0.0.0:8081
ports:
- "8081:8081"
# GCS Emulator (fake-gcs-server)
gcs:
image: fsouza/fake-gcs-server
command: -scheme http -port 4443
ports:
- "4443:4443"
volumes:
- gcs-data:/data
# Control Plane API
control-plane:
build:
context: ./backend/control-plane
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- PORT=8080
- GCP_PROJECT_ID=productos-local
- GCS_BUCKET_ARTIFACTS=productos-artifacts-local
- FIRESTORE_COLLECTION_RUNS=runs
- FIRESTORE_COLLECTION_TOOLS=tools
- AUTH_MODE=dev
- FIRESTORE_EMULATOR_HOST=firestore:8081
- STORAGE_EMULATOR_HOST=http://gcs:4443
depends_on:
- firestore
- gcs
volumes:
gcs-data:

View File

@@ -0,0 +1,143 @@
# Product OS - Getting Started
## Project Structure
```
platform/
├── backend/
│ └── control-plane/ # Fastify API server
├── client-ide/
│ └── extensions/
│ └── gcp-productos/ # VSCodium/VS Code extension
├── contracts/ # Tool registry schemas
├── infra/
│ └── terraform/ # GCP infrastructure
├── docs/ # Documentation
└── docker-compose.yml # Local development
```
## Quick Start (Local Development)
### Prerequisites
- Node.js 22+
- Docker & Docker Compose
- (Optional) VS Code or VSCodium for extension development
### 1. Start Local Services
```bash
cd platform
docker-compose up -d
```
This starts:
- Firestore emulator on port 8081
- GCS emulator on port 4443
- Control Plane API on port 8080
### 2. Run Control Plane in Dev Mode
For faster iteration without Docker:
```bash
cd platform/backend/control-plane
cp env.example .env
npm install
npm run dev
```
### 3. Test the API
```bash
# Health check
curl http://localhost:8080/healthz
# List tools (empty initially)
curl http://localhost:8080/tools
# Invoke a tool (dry run)
curl -X POST http://localhost:8080/tools/invoke \
-H "Content-Type: application/json" \
-d '{
"tool": "cloudrun.deploy_service",
"tenant_id": "t_dev",
"input": {"service_name": "test"},
"dry_run": true
}'
```
### 4. Build & Install the Extension
```bash
cd platform/client-ide/extensions/gcp-productos
npm install
npm run build
```
Then in VS Code / VSCodium:
1. Open Command Palette (Cmd+Shift+P)
2. Run "Developer: Install Extension from Location..."
3. Select the `gcp-productos` folder
Or use the VSIX package:
```bash
npx vsce package
code --install-extension gcp-productos-0.0.1.vsix
```
## Extension Usage
Once installed, use the Command Palette:
- **Product OS: Configure Backend** - Set the Control Plane URL
- **Product OS: List Tools** - View available tools
- **Product OS: Invoke Tool** - Execute a tool
- **Product OS: Open Run** - View run details
## Deploying to GCP
### 1. Configure Terraform
```bash
cd platform/infra/terraform
cp terraform.tfvars.example terraform.tfvars
# Edit terraform.tfvars with your project details
```
### 2. Build & Push Container
```bash
cd platform/backend/control-plane
# Build
docker build -t us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest .
# Push (requires gcloud auth)
docker push us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest
```
### 3. Apply Terraform
```bash
cd platform/infra/terraform
terraform init
terraform plan
terraform apply
```
## Seeding Tools
To add tools to the registry, you can:
1. Use the Firestore console to add documents to the `tools` collection
2. Create a seed script that loads `contracts/tool-registry.yaml`
3. Build an admin endpoint (coming in v2)
## Next Steps
- [ ] Build Deploy Executor
- [ ] Build Analytics Executor
- [ ] Add Gemini integration
- [ ] Add OAuth/IAP authentication
- [ ] Create Product-Centric UI panels

View File

@@ -0,0 +1,16 @@
# Allow control-plane to write artifacts in GCS
resource "google_storage_bucket_iam_member" "control_plane_bucket_writer" {
bucket = google_storage_bucket.artifacts.name
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
}
# Firestore access for run/tool metadata
resource "google_project_iam_member" "control_plane_firestore" {
project = var.project_id
role = "roles/datastore.user"
member = "serviceAccount:${google_service_account.control_plane_sa.email}"
}
# Placeholder: executor services will each have their own service accounts.
# Control-plane should be granted roles/run.invoker on each executor service once created.

View File

@@ -0,0 +1,54 @@
# GCS Bucket for artifacts (logs, AI outputs, patches)
resource "google_storage_bucket" "artifacts" {
name = var.artifact_bucket_name
location = var.region
uniform_bucket_level_access = true
versioning { enabled = true }
}
# Firestore (Native mode) requires enabling in console once per project
resource "google_firestore_database" "default" {
name = "(default)"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
# Service account for Control Plane
resource "google_service_account" "control_plane_sa" {
account_id = "sa-control-plane"
display_name = "Product OS Control Plane"
}
# Cloud Run service for Control Plane API
resource "google_cloud_run_v2_service" "control_plane" {
name = "control-plane"
location = var.region
template {
service_account = google_service_account.control_plane_sa.email
containers {
image = var.control_plane_image
env {
name = "GCP_PROJECT_ID"
value = var.project_id
}
env {
name = "GCS_BUCKET_ARTIFACTS"
value = google_storage_bucket.artifacts.name
}
env {
name = "AUTH_MODE"
value = "dev"
}
}
}
}
# Public access for dev; prefer IAM auth in production
resource "google_cloud_run_v2_service_iam_member" "control_plane_public" {
name = google_cloud_run_v2_service.control_plane.name
location = var.region
role = "roles/run.invoker"
member = "allUsers"
}

View File

@@ -0,0 +1,9 @@
output "control_plane_url" {
value = google_cloud_run_v2_service.control_plane.uri
description = "URL of the Control Plane API"
}
output "artifact_bucket" {
value = google_storage_bucket.artifacts.name
description = "GCS bucket for artifacts"
}

View File

@@ -0,0 +1,14 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.30"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}

View File

@@ -0,0 +1,4 @@
project_id = "your-gcp-project-id"
region = "us-central1"
artifact_bucket_name = "productos-artifacts-dev"
control_plane_image = "us-central1-docker.pkg.dev/YOUR_PROJECT/productos/control-plane:latest"

View File

@@ -0,0 +1,20 @@
variable "project_id" {
type = string
description = "GCP Project ID"
}
variable "region" {
type = string
default = "us-central1"
description = "GCP region for resources"
}
variable "artifact_bucket_name" {
type = string
description = "Name for the GCS bucket storing artifacts"
}
variable "control_plane_image" {
type = string
description = "Container image URI for control-plane (Artifact Registry)."
}

View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Start all Product OS services for local development
echo "🚀 Starting Product OS services..."
cd "$(dirname "$0")/.."
# Start Control Plane
echo "Starting Control Plane (port 8080)..."
cd backend/control-plane
npm run dev &
CONTROL_PLANE_PID=$!
cd ../..
sleep 2
# Start Deploy Executor
echo "Starting Deploy Executor (port 8090)..."
cd backend/executors/deploy
npm run dev &
DEPLOY_PID=$!
cd ../../..
# Start Analytics Executor
echo "Starting Analytics Executor (port 8091)..."
cd backend/executors/analytics
npm run dev &
ANALYTICS_PID=$!
cd ../../..
# Start Marketing Executor
echo "Starting Marketing Executor (port 8093)..."
cd backend/executors/marketing
npm run dev &
MARKETING_PID=$!
cd ../../..
echo ""
echo "✅ All services started!"
echo ""
echo "Services:"
echo " - Control Plane: http://localhost:8080"
echo " - Deploy Executor: http://localhost:8090"
echo " - Analytics Executor: http://localhost:8091"
echo " - Marketing Executor: http://localhost:8093"
echo ""
echo "Press Ctrl+C to stop all services"
# Wait for any process to exit
wait
# Cleanup
kill $CONTROL_PLANE_PID $DEPLOY_PID $ANALYTICS_PID $MARKETING_PID 2>/dev/null

0
technical_spec.md Normal file
View File

0
vision-ext.md Normal file
View File