Merge remote main branch with local changes

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-10 13:23:50 -08:00
57 changed files with 8150 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.

View File

@@ -0,0 +1,743 @@
1) Generate Control Plane API scaffold
Folder layout
backend/control-plane/
package.json
tsconfig.json
src/
index.ts
config.ts
auth.ts
registry.ts
types.ts
storage/
firestore.ts
gcs.ts
routes/
tools.ts
runs.ts
health.ts
.env.example
backend/control-plane/package.json
{
"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": "^9.0.1",
"@fastify/helmet": "^12.0.0",
"@fastify/rate-limit": "^9.1.0",
"fastify": "^4.28.1",
"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"
}
}
backend/control-plane/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"]
}
}
backend/control-plane/.env.example
PORT=8080
GCP_PROJECT_ID=your-project-id
GCS_BUCKET_ARTIFACTS=productos-artifacts-dev
FIRESTORE_COLLECTION_RUNS=runs
FIRESTORE_COLLECTION_TOOLS=tools
# If you put behind IAP / OAuth later, validate ID tokens here:
AUTH_MODE=dev # dev | oauth
backend/control-plane/src/config.ts
export const config = {
port: Number(process.env.PORT ?? 8080),
projectId: process.env.GCP_PROJECT_ID ?? "",
artifactsBucket: process.env.GCS_BUCKET_ARTIFACTS ?? "",
runsCollection: process.env.FIRESTORE_COLLECTION_RUNS ?? "runs",
toolsCollection: process.env.FIRESTORE_COLLECTION_TOOLS ?? "tools",
authMode: process.env.AUTH_MODE ?? "dev"
};
backend/control-plane/src/types.ts
export type ToolRisk = "low" | "medium" | "high";
export type ToolDef = {
name: string;
description: string;
risk: ToolRisk;
executor: {
kind: "http";
url: string; // executor base url
path: string; // executor endpoint path
};
inputSchema: unknown; // JSON Schema object
outputSchema?: unknown; // JSON Schema object
};
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 };
};
backend/control-plane/src/auth.ts
import { FastifyRequest } from "fastify";
import { config } from "./config.js";
/**
* V1: dev mode = trust caller (or a shared API key later).
* V2: validate Google OAuth/IAP identity token and map to tenant/org.
*/
export async function requireAuth(req: FastifyRequest) {
if (config.authMode === "dev") return;
// Placeholder for OAuth/IAP verification:
// - read Authorization: Bearer <id_token>
// - verify token (Google JWKS)
// - attach req.user
throw new Error("AUTH_MODE oauth not yet implemented");
}
backend/control-plane/src/storage/firestore.ts
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);
}
backend/control-plane/src/storage/gcs.ts
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}` };
}
backend/control-plane/src/registry.ts
import type { ToolDef } from "./types.js";
import { listTools } from "./storage/firestore.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]));
}
backend/control-plane/src/routes/health.ts
import type { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) {
app.get("/healthz", async () => ({ ok: true }));
}
backend/control-plane/src/routes/tools.ts
import type { FastifyInstance } from "fastify";
import { nanoid } from "nanoid";
import { requireAuth } from "../auth.js";
import { getRegistry } from "../registry.js";
import { saveRun } from "../storage/firestore.js";
import { writeArtifactText } from "../storage/gcs.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);
// record input artifact
await writeArtifactText(`runs/${runId}`, "input.json", JSON.stringify(body, null, 2));
// execute (sync for v1; v2: push to Cloud Tasks / Workflows)
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 };
}
});
}
backend/control-plane/src/routes/runs.ts
import type { FastifyInstance } from "fastify";
import { requireAuth } from "../auth.js";
import { getRun } from "../storage/firestore.js";
export async function runRoutes(app: FastifyInstance) {
app.get("/runs/:run_id", async (req) => {
await requireAuth(req);
// @ts-expect-error fastify param typing
const runId = req.params.run_id as string;
const run = await getRun(runId);
if (!run) return app.httpErrors.notFound("Run not found");
return run;
});
// V1: logs are stored as artifacts in GCS; IDE can fetch by signed URL later
app.get("/runs/:run_id/logs", async (req) => {
await requireAuth(req);
// stub
return { note: "V1: logs are in GCS artifacts under runs/<run_id>/" };
});
}
backend/control-plane/src/index.ts
import Fastify from "fastify";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import rateLimit from "@fastify/rate-limit";
import { config } from "./config.js";
import { healthRoutes } from "./routes/health.js";
import { toolRoutes } from "./routes/tools.js";
import { runRoutes } from "./routes/runs.js";
const app = Fastify({ logger: true });
await app.register(cors, { origin: true });
await app.register(helmet);
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(healthRoutes);
await app.register(toolRoutes);
await app.register(runRoutes);
app.listen({ port: config.port, host: "0.0.0.0" }).catch((err) => {
app.log.error(err);
process.exit(1);
});
2) Generate Tool Registry schema
You want two things:
A human-editable YAML (source of truth)
A JSON Schema to validate tool definitions
contracts/tool-registry.yaml (example)
version: 1
tools:
cloudrun.deploy_service:
description: Deploy a Cloud Run service via Cloud Build.
risk: medium
executor:
kind: http
url: https://deploy-executor-xxxxx.a.run.app
path: /execute/deploy
inputSchema:
type: object
required: [service_name, repo, ref, env]
properties:
service_name: { type: string }
repo: { type: string, description: "Git repo URL" }
ref: { type: string, description: "Branch, tag, or commit SHA" }
env: { type: string, enum: ["dev", "staging", "prod"] }
outputSchema:
type: object
properties:
service_url: { type: string }
revision: { type: string }
analytics.get_funnel_summary:
description: Return funnel metrics for a tenant and time window.
risk: low
executor:
kind: http
url: https://analytics-executor-xxxxx.a.run.app
path: /execute/funnel
inputSchema:
type: object
required: [range_days]
properties:
range_days: { type: integer, minimum: 1, maximum: 365 }
segment: { type: object }
contracts/tool-registry.schema.json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://productos.dev/schemas/tool-registry.schema.json",
"type": "object",
"required": ["version", "tools"],
"properties": {
"version": { "type": "integer", "minimum": 1 },
"tools": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/ToolDef" }
}
},
"$defs": {
"ToolDef": {
"type": "object",
"required": ["description", "risk", "executor", "inputSchema"],
"properties": {
"description": { "type": "string" },
"risk": { "type": "string", "enum": ["low", "medium", "high"] },
"executor": { "$ref": "#/$defs/Executor" },
"inputSchema": { "type": "object" },
"outputSchema": { "type": "object" }
},
"additionalProperties": false
},
"Executor": {
"type": "object",
"required": ["kind", "url", "path"],
"properties": {
"kind": { "type": "string", "enum": ["http"] },
"url": { "type": "string" },
"path": { "type": "string" }
},
"additionalProperties": false
}
}
}
3) Generate VSCodium extension skeleton
Folder layout
client-ide/extensions/gcp-productos/
package.json
tsconfig.json
src/extension.ts
src/api.ts
src/ui.ts
media/icon.png (optional)
client-ide/extensions/gcp-productos/package.json
{
"name": "gcp-productos",
"displayName": "GCP Product OS",
"description": "Product-centric panels (Code, Marketing, Analytics, Growth...) and backend tool invocation.",
"version": "0.0.1",
"publisher": "productos",
"engines": { "vscode": "^1.90.0" },
"categories": ["Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"contributes": {
"commands": [
{ "command": "productos.configure", "title": "Product OS: Configure Backend" },
{ "command": "productos.tools.list", "title": "Product OS: List Tools" },
{ "command": "productos.tools.invoke", "title": "Product OS: Invoke Tool" },
{ "command": "productos.runs.open", "title": "Product OS: Open Run" }
],
"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"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.90.0",
"typescript": "^5.5.4"
}
}
client-ide/extensions/gcp-productos/tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true
}
}
client-ide/extensions/gcp-productos/src/api.ts
import * as vscode from "vscode";
function cfg<T>(key: string): T {
return vscode.workspace.getConfiguration("productos").get<T>(key)!;
}
export function backendUrl() {
return cfg<string>("backendUrl");
}
export function tenantId() {
return cfg<string>("tenantId");
}
export async function listTools(): Promise<any[]> {
const res = await fetch(`${backendUrl()}/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) {
const res = await fetch(`${backendUrl()}/tools/invoke`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
tool,
tenant_id: tenantId(),
input
})
});
if (!res.ok) throw new Error(await res.text());
return res.json();
}
export async function getRun(runId: string) {
const res = await fetch(`${backendUrl()}/runs/${runId}`);
if (!res.ok) throw new Error(await res.text());
return res.json();
}
client-ide/extensions/gcp-productos/src/ui.ts
import * as vscode from "vscode";
import { getRun } 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 showJson(`Run ${runId}`, run);
}
client-ide/extensions/gcp-productos/src/extension.ts
import * as vscode from "vscode";
import { invokeTool, listTools } from "./api";
import { openRun, showJson } from "./ui";
export function activate(context: vscode.ExtensionContext) {
context.subscriptions.push(
vscode.commands.registerCommand("productos.configure", async () => {
const backendUrl = await vscode.window.showInputBox({ prompt: "Control Plane backend URL" });
if (!backendUrl) return;
await vscode.workspace.getConfiguration("productos").update("backendUrl", backendUrl, vscode.ConfigurationTarget.Global);
vscode.window.showInformationMessage(`Product OS backend set: ${backendUrl}`);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.list", async () => {
const tools = await listTools();
await showJson("Tools", tools);
})
);
context.subscriptions.push(
vscode.commands.registerCommand("productos.tools.invoke", async () => {
const tools = await listTools();
const pick = await vscode.window.showQuickPick(
tools.map((t: any) => ({ label: t.name, description: t.description })),
{ placeHolder: "Select a tool to invoke" }
);
if (!pick) return;
const inputText = await vscode.window.showInputBox({
prompt: "Tool input JSON",
value: "{}"
});
if (!inputText) return;
const input = JSON.parse(inputText);
const result = await invokeTool(pick.label, input);
await showJson("Invoke Result", result);
if (result?.run_id) {
const open = await vscode.window.showInformationMessage(`Run started: ${result.run_id}`, "Open Run");
if (open === "Open Run") await openRun(result.run_id);
}
})
);
context.subscriptions.push(
vscode.commands.registerCommand("productos.runs.open", async () => {
const runId = await vscode.window.showInputBox({ prompt: "Run ID" });
if (!runId) return;
await openRun(runId);
})
);
}
export function deactivate() {}
4) Generate Terraform base
This is a minimal hub-style baseline:
GCS bucket for artifacts
Firestore (Native) for runs/tools
Cloud Run service for Control Plane
Service accounts + IAM
Placeholders for executor services
Folder layout
infra/terraform/
providers.tf
variables.tf
outputs.tf
main.tf
iam.tf
infra/terraform/providers.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
google = {
source = "hashicorp/google"
version = "~> 5.30"
}
}
}
provider "google" {
project = var.project_id
region = var.region
}
infra/terraform/variables.tf
variable "project_id" { type = string }
variable "region" { type = string default = "us-central1" }
variable "artifact_bucket_name" { type = string }
variable "control_plane_image" {
type = string
description = "Container image URI for control-plane (Artifact Registry)."
}
infra/terraform/main.tf
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 (or via API depending on org policy).
resource "google_firestore_database" "default" {
name = "(default)"
location_id = var.region
type = "FIRESTORE_NATIVE"
}
resource "google_service_account" "control_plane_sa" {
account_id = "sa-control-plane"
display_name = "Product OS Control Plane"
}
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 optional; 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"
}
infra/terraform/iam.tf
# 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.
infra/terraform/outputs.tf
output "control_plane_url" {
value = google_cloud_run_v2_service.control_plane.uri
}
output "artifact_bucket" {
value = google_storage_bucket.artifacts.name
}

949
architecture.md Normal file
View File

@@ -0,0 +1,949 @@
1) Recommended reference architecture (Web SaaS-first, 1 product = 1 GCP project per env)
Project model
One product = one GCP project per environment
product-foo-dev
product-foo-staging
product-foo-prod
Optional “platform” projects (yours, not the customers):
productos-control-plane (your backend + tool registry + auth)
productos-observability (optional central dashboards / cross-product rollups)
productos-billing-export (optional BigQuery billing export aggregation)
High-level runtime pattern
IDE + Supervisor AI never touch DBs/services directly.
They call your Control Plane API, which routes to domain Executors (Cloud Run services) with least-privilege service accounts.
VSCodium IDE (Product OS UI) Supervisor AI (Vertex)
\ /
\ /
-----> Control Plane API ----
|
-------------------------------------------------
| | | | |
Deploy Exec Analytics Exec Firestore SQL Exec Marketing Exec
(Cloud Build (BigQuery jobs) Exec Exec (Missinglettr,
+ Cloud Run) (Company (Cloud email provider)
Brain) SQL)
Per-product (customer) project: “product-foo-prod”
Must-have services
Cloud Run: product services + executors (if you deploy executors into product project)
Cloud SQL (Postgres/MySQL): transactional app data
Firestore: config + “Company Brain” + style profiles + run metadata (if you keep metadata per product)
BigQuery: event warehouse + analytics datasets/views + experimentation tables
Pub/Sub: event bus for product events + tool events
Cloud Tasks / Workflows / Scheduler: durable automation + cron-based routines
Secret Manager: tokens, DB creds, OAuth secrets (never in code)
Logging/Monitoring/Trace: observability
Where to place executors
Simplest: executors live in the product project (tight coupling, simple data access)
More “platform”: executors live in your platform project, and access product resources cross-project (strong central control, but more IAM + org policy considerations)
For your “product per project” approach, I recommend:
Deploy executor can live in platform (deploy across projects)
Data executors (SQL/Firestore/BigQuery) often live in product project (least-cross-project permissions)
Data flows
Events: Product apps → Pub/Sub → BigQuery (raw + curated)
Causation/insights: Analytics Exec reads BigQuery → writes Insight Objects to:
BigQuery tables (truth)
GCS artifacts (reports)
Firestore (summary pointers for UI)
Marketing: Marketing Exec pulls Insight Objects + Company Brain → generates campaigns → publishes via Missinglettr/social APIs; stores outputs in GCS + metadata in Firestore
2) Service-by-service IAM roles matrix (least privilege template)
Identities (service accounts)
Youll typically have:
sa-control-plane (platform): routes tool calls, enforces policy, writes run metadata/artifacts
sa-deploy-executor (platform): triggers builds and deploys to Cloud Run in product projects
sa-analytics-executor (product): reads BigQuery + writes insights
sa-firestore-executor (product): reads/writes Company Brain + configs
sa-sql-executor (product): connects to Cloud SQL (plus DB user for SQL-level permissions)
sa-marketing-executor (platform or product): reads insights + calls Missinglettr/email providers; reads secrets
Where I say “product project”, apply it to each env project (dev/staging/prod).
IAM matrix (by service)
Service / Scope Principal Roles (suggested) Notes
Cloud Run (product) sa-deploy-executor roles/run.admin (or narrower), roles/iam.serviceAccountUser (only on the runtime SA), roles/run.invoker (optional) Deploy revisions. Narrow iam.serviceAccountUser to only the runtime SA used by the service being deployed.
Cloud Build (platform or product) sa-deploy-executor roles/cloudbuild.builds.editor (or builds.builder depending on workflow) Triggers builds. Many teams keep builds centralized in platform.
Artifact Registry sa-deploy-executor roles/artifactregistry.writer Push images. If per-product registries, scope accordingly.
Secret Manager (platform/product) sa-marketing-executor, sa-deploy-executor roles/secretmanager.secretAccessor Only for the specific secrets needed.
BigQuery dataset (product) sa-analytics-executor roles/bigquery.dataViewer + roles/bigquery.jobUser Dataset-level grants. Prefer views/curated datasets.
BigQuery dataset (product write) sa-analytics-executor roles/bigquery.dataEditor (only for insight tables dataset) Separate datasets: events_raw (read), events_curated (read), insights (write).
Firestore (product) sa-firestore-executor roles/datastore.user (or roles/datastore.viewer) Use viewer when possible; writer only for Brain/config updates.
Cloud SQL (product) sa-sql-executor roles/cloudsql.client IAM to connect; SQL permissions handled by DB user(s).
Pub/Sub (product) Producers roles/pubsub.publisher For product services emitting events.
Pub/Sub (product) Consumers/executors roles/pubsub.subscriber For analytics/executor ingestion.
Cloud Tasks (product/platform) sa-control-plane or orchestrator roles/cloudtasks.enqueuer + roles/cloudtasks.viewer If you queue tool runs or retries.
Workflows (product/platform) sa-control-plane roles/workflows.invoker For orchestrated multi-step automations.
Cloud Storage (GCS artifacts) sa-control-plane roles/storage.objectAdmin (bucket-level) Write run artifacts; consider objectCreator + separate delete policy if you want immutability.
Cloud Run executors (wherever hosted) sa-control-plane roles/run.invoker Control Plane calls executors over HTTP.
Strongly recommended scoping rules
Grant BigQuery roles at the dataset level, not project level.
Use separate datasets for raw, curated, and insights.
For Cloud SQL, enforce read-only DB users for most endpoints; create a separate writer user only when needed.
Keep a “high risk” policy that requires approval for:
pricing changes
billing actions
production destructive infra
legal/claim-heavy marketing copy
3) Agent tool catalog (seed tool registry mapped to GCP services)
This is a starter “tool universe” your Supervisor AI + IDE can call. Ive grouped by module and listed the backing GCP service.
A) Code module (build/test/deploy)
Tool name Purpose Executes in Backed by
repo.apply_patch Apply diff to repo (local or PR flow) Control Plane / Repo service (GitHub App or local workspace)
repo.open_pr Open PR with changes Control Plane GitHub App
build.run_tests Run unit tests Executor (local/offline or remote) Cloud Build / local runner
cloudrun.deploy_service Build + deploy service Deploy Exec Cloud Build + Cloud Run
cloudrun.rollback_service Roll back revision Deploy Exec Cloud Run
cloudrun.get_service_status Health, revisions, URL Deploy Exec Cloud Run
logs.tail Tail logs for service/run Observability Exec Cloud Logging
B) Marketing module (campaign creation + publishing)
Tool name Purpose Executes in Backed by
brand.get_profile Fetch voice/style/claims Firestore Exec Firestore
brand.update_profile Update voice/style rules Firestore Exec Firestore
marketing.generate_campaign_plan Create campaign plan from insight/product update Marketing Exec Vertex AI (Gemini)
marketing.generate_channel_posts Generate platform-specific posts Marketing Exec Vertex AI (Gemini)
marketing.publish_missinglettr Schedule/publish via Missinglettr Marketing Exec Missinglettr API + Secret Manager
marketing.publish_email Send email campaign Marketing Exec Email provider (SendGrid/etc) + Secret Manager
marketing.store_assets Save creatives/outputs Marketing Exec GCS
marketing.get_campaign_status Poll publish status Marketing Exec Missinglettr / provider APIs
C) Analytics module (events, funnels, causation)
Tool name Purpose Executes in Backed by
events.ingest Ingest events (if you own ingestion endpoint) Analytics/Ingress Exec Pub/Sub + BigQuery
analytics.funnel_summary Funnel metrics Analytics Exec BigQuery
analytics.cohort_retention Retention cohorts Analytics Exec BigQuery
analytics.anomaly_detect Detect anomalies in KPIs Analytics Exec BigQuery / BQML
analytics.top_drivers Feature/sequence drivers Analytics Exec BigQuery / BQML / Vertex
analytics.causal_uplift Uplift/causal impact estimate Analytics Exec BigQuery + Vertex (optional)
analytics.write_insight Persist insight object Analytics Exec BigQuery + Firestore pointer + GCS artifact
D) Growth module (onboarding + lifecycle optimization)
Tool name Purpose Executes in Backed by
growth.identify_dropoffs Identify where users drop Analytics Exec BigQuery
growth.propose_experiment Generate experiment hypothesis/design Growth Exec Gemini + policies
experiments.create Create experiment definition Experiments Exec Firestore/SQL + your assignment service
experiments.evaluate Evaluate results Analytics/Experiments Exec BigQuery
growth.generate_lifecycle_messages Draft onboarding/lifecycle content Marketing/Growth Exec Gemini
E) Support module (feedback + ticket assist)
Tool name Purpose Executes in Backed by
support.ingest_tickets Pull tickets from provider Support Exec Zendesk/Intercom API
support.summarize_ticket Summarize and classify Support Exec Gemini
support.draft_reply Draft response Support Exec Gemini + brand profile
support.update_kb Generate/update KB article Support Exec CMS/Docs + GCS
support.escalate_issue Create issue/task Support Exec GitHub Issues/Jira/etc
F) Infrastructure module (safe, templated ops only)
Tool name Purpose Executes in Backed by
infra.provision_service_template Create a Cloud Run service template Infra Exec Terraform/Cloud APIs
infra.provision_database Create Cloud SQL/Firestore config Infra Exec Cloud SQL / Firestore
infra.provision_pubsub Topics/subscriptions Infra Exec Pub/Sub
infra.rotate_secret Rotate/refresh secrets Infra Exec Secret Manager
infra.cost_report Cost summary for product Analytics/FinOps Exec Billing export → BigQuery
G) Auth module (product auth + internal access)
Tool name Purpose Executes in Backed by
auth.configure_identity_platform Set up end-user auth Auth/Infra Exec Identity Platform
auth.configure_iap Protect internal tools Infra Exec IAP
auth.create_oauth_client Create OAuth creds for integrations Infra Exec Google OAuth / Secret Manager
Recommended “v1 tool registry seed” (small, shippable)
If you want a tight first release, seed only these tools:
Code
cloudrun.deploy_service
cloudrun.get_service_status
logs.tail
Company Brain
brand.get_profile
brand.update_profile
Analytics
analytics.funnel_summary
analytics.top_drivers
analytics.write_insight
Marketing
marketing.generate_channel_posts
marketing.publish_missinglettr
Thats enough to demonstrate the full Product OS loop:
deploy → events → insight → campaign → publish → measure.
If you want, I can also output this as an actual tool-registry.yaml starter file (matching the schema we drafted earlier) with concrete JSON Schemas for each of the v1 tools.
yes gor for it
Heres a starter tool-registry.yaml you can drop into contracts/tool-registry.yaml. It matches the schema we defined earlier and includes concrete JSON Schemas for the v1 seed tools:
cloudrun.deploy_service
cloudrun.get_service_status
logs.tail
brand.get_profile
brand.update_profile
analytics.funnel_summary
analytics.top_drivers
analytics.write_insight
marketing.generate_channel_posts
marketing.publish_missinglettr
Replace the executor.url placeholders with your actual Cloud Run service URLs.
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).
build:
type: object
additionalProperties: false
properties:
dockerfile_path:
type: string
default: Dockerfile
build_context:
type: string
default: .
env_vars:
type: object
additionalProperties:
type: string
description: Environment variables to set during build/deploy (non-secret).
deploy:
type: object
additionalProperties: false
properties:
cpu:
type: string
description: Cloud Run CPU (e.g., "1", "2").
memory:
type: string
description: Cloud Run memory (e.g., "512Mi", "1Gi").
min_instances:
type: integer
minimum: 0
max_instances:
type: integer
minimum: 1
concurrency:
type: integer
minimum: 1
timeout_seconds:
type: integer
minimum: 1
maximum: 3600
service_account_email:
type: string
description: Runtime service account email for the Cloud Run service.
allow_unauthenticated:
type: boolean
default: false
outputSchema:
type: object
additionalProperties: false
required: [service_url, revision]
properties:
service_url:
type: string
revision:
type: string
build_id:
type: string
warnings:
type: array
items:
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
additionalProperties: false
required: [service_name, region, service_url, latest_ready_revision, status]
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]
last_deploy_time:
type: string
description: ISO timestamp if available.
logs.tail:
description: Tail recent logs for a Cloud Run service or for a specific run_id. Returns log lines (best-effort).
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]
description: Tail logs by service or by tool run.
service_name:
type: string
description: Required if scope=service.
region:
type: string
description: Optional when scope=service, depending on your log query strategy.
run_id:
type: string
description: Required if scope=run.
limit:
type: integer
minimum: 1
maximum: 2000
default: 200
since_seconds:
type: integer
minimum: 1
maximum: 86400
default: 900
outputSchema:
type: object
additionalProperties: false
required: [lines]
properties:
lines:
type: array
items:
type: object
additionalProperties: false
required: [timestamp, text]
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
additionalProperties: false
required: [profile_id, brand]
properties:
profile_id:
type: string
brand:
type: object
additionalProperties: false
required: [name, voice, audience, claims_policy]
properties:
name:
type: string
voice:
type: object
additionalProperties: false
required: [tone, style_notes, do, dont]
properties:
tone:
type: array
items: { type: string }
style_notes:
type: array
items: { type: string }
do:
type: array
items: { type: string }
dont:
type: array
items: { type: string }
audience:
type: object
additionalProperties: false
properties:
primary:
type: string
secondary:
type: string
claims_policy:
type: object
additionalProperties: false
properties:
forbidden_claims:
type: array
items: { type: string }
required_disclaimers:
type: array
items: { type: string }
compliance_notes:
type: array
items: { type: string }
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
additionalProperties: false
required: [ok, updated_at]
properties:
ok:
type: boolean
updated_at:
type: string
# ----------------------------
# ANALYTICS / CAUSATION (V1 metrics + drivers)
# ----------------------------
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
additionalProperties: false
required: [name, steps]
properties:
name:
type: string
steps:
type: array
minItems: 2
items:
type: object
additionalProperties: false
required: [event_name]
properties:
event_name:
type: string
filter:
type: object
description: Optional event property filters (executor-defined).
segment:
type: object
description: Optional segment definition (executor-defined).
outputSchema:
type: object
additionalProperties: false
required: [funnel_name, range_days, steps]
properties:
funnel_name:
type: string
range_days:
type: integer
steps:
type: array
items:
type: object
additionalProperties: false
required: [event_name, users, conversion_from_prev]
properties:
event_name:
type: string
users:
type: integer
minimum: 0
conversion_from_prev:
type: number
minimum: 0
maximum: 1
analytics.top_drivers:
description: Identify top correlated drivers for a target metric/event (v1: correlation/feature importance; later: causality).
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
additionalProperties: false
required: [metric]
properties:
metric:
type: string
description: Named metric (e.g., "trial_to_paid", "activation_rate") or event-based metric.
event_name:
type: string
description: Optional: if metric is event-based, supply event_name.
candidate_features:
type: array
items:
type: string
description: Optional list of features/properties to consider.
segment:
type: object
description: Optional segmentation.
outputSchema:
type: object
additionalProperties: false
required: [target, range_days, drivers]
properties:
target:
type: object
range_days:
type: integer
drivers:
type: array
items:
type: object
additionalProperties: false
required: [name, score, direction, evidence]
properties:
name:
type: string
score:
type: number
direction:
type: string
enum: [positive, negative, mixed, unknown]
evidence:
type: string
description: Human-readable summary of why this driver matters.
confidence:
type: number
minimum: 0
maximum: 1
analytics.write_insight:
description: Persist an insight object (BigQuery table + Firestore pointer + GCS artifact). Returns an insight_id.
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
additionalProperties: false
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
additionalProperties: false
required: [range_days]
properties:
range_days:
type: integer
minimum: 1
maximum: 365
context:
type: object
description: Arbitrary structured context (metric names, segments, charts pointers).
recommendations:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required: [action, rationale]
properties:
action:
type: string
rationale:
type: string
links:
type: array
items:
type: object
additionalProperties: false
required: [label, url]
properties:
label: { type: string }
url: { type: string }
outputSchema:
type: object
additionalProperties: false
required: [insight_id, stored]
properties:
insight_id:
type: string
stored:
type: object
additionalProperties: false
required: [bigquery, firestore, gcs]
properties:
bigquery:
type: object
additionalProperties: false
required: [dataset, table]
properties:
dataset: { type: string }
table: { type: string }
firestore:
type: object
additionalProperties: false
required: [collection, doc_id]
properties:
collection: { type: string }
doc_id: { type: string }
gcs:
type: object
additionalProperties: false
required: [bucket, prefix]
properties:
bucket: { type: string }
prefix: { type: string }
# ----------------------------
# 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
description: Brand profile id to load (e.g., "default").
brief:
type: object
additionalProperties: false
required: [goal, product, audience, key_points]
properties:
goal:
type: string
description: What outcome are we driving? (e.g., "trial signups")
product:
type: string
audience:
type: string
key_points:
type: array
minItems: 1
items: { type: string }
offer:
type: string
call_to_action:
type: string
landing_page_url:
type: string
channels:
type: array
minItems: 1
items:
type: string
enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit, google_business, mastodon, bluesky, threads]
variations_per_channel:
type: integer
minimum: 1
maximum: 10
default: 3
constraints:
type: object
additionalProperties: false
properties:
max_length:
type: integer
minimum: 50
maximum: 4000
emoji_level:
type: string
enum: [none, light, medium, heavy]
default: light
include_hashtags:
type: boolean
default: true
outputSchema:
type: object
additionalProperties: false
required: [channels]
properties:
channels:
type: array
items:
type: object
additionalProperties: false
required: [channel, posts]
properties:
channel:
type: string
posts:
type: array
items:
type: object
additionalProperties: false
required: [text]
properties:
text: { type: string }
title: { type: string }
alt_text: { type: string }
hashtags:
type: array
items: { type: string }
media_suggestions:
type: array
items: { type: string }
marketing.publish_missinglettr:
description: Publish or schedule a campaign via Missinglettr using stored OAuth/token secrets.
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
additionalProperties: false
required: [name, posts]
properties:
name:
type: string
posts:
type: array
minItems: 1
items:
type: object
additionalProperties: false
required: [channel, text]
properties:
channel:
type: string
enum: [x, linkedin, facebook, instagram, tiktok, youtube, pinterest, reddit, google_business, mastodon, bluesky, threads]
text:
type: string
media_urls:
type: array
items: { type: string }
link_url:
type: string
schedule:
type: object
additionalProperties: false
required: [mode]
properties:
mode:
type: string
enum: [now, scheduled]
start_time:
type: string
description: ISO timestamp required if mode=scheduled.
timezone:
type: string
default: UTC
idempotency_key:
type: string
description: Optional idempotency key to prevent duplicates.
outputSchema:
type: object
additionalProperties: false
required: [provider, campaign_id, status]
properties:
provider:
type: string
enum: [missinglettr]
campaign_id:
type: string
status:
type: string
enum: [queued, scheduled, published, failed]
provider_response:
type: object
description: Raw provider response (redacted as needed).

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,113 @@
{
"name": "gcp-productos",
"displayName": "GCP Product OS",
"description": "Product-centric IDE for launching and operating SaaS products on Google Cloud. Use @productos in chat!",
"version": "0.2.0",
"publisher": "productos",
"engines": { "vscode": "^1.90.0" },
"categories": ["AI", "Chat", "Other"],
"activationEvents": ["onStartupFinished"],
"main": "./dist/extension.js",
"contributes": {
"chatParticipants": [
{
"id": "productos.chat",
"fullName": "Product OS",
"name": "productos",
"description": "Deploy, analyze, and automate your SaaS product on Google Cloud",
"isSticky": true,
"commands": [
{
"name": "deploy",
"description": "Deploy a service to Cloud Run"
},
{
"name": "analytics",
"description": "Get funnel and conversion analytics"
},
{
"name": "marketing",
"description": "Generate marketing content"
},
{
"name": "status",
"description": "Check service status"
}
]
}
],
"viewsContainers": {
"activitybar": [
{
"id": "productos",
"title": "Product OS",
"icon": "media/icon.svg"
}
]
},
"views": {
"productos": [
{
"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.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)" }
],
"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,223 @@
import * as vscode from "vscode";
import { getBackendUrl } from "./api";
/**
* Product OS Chat Participant
*
* Registers @productos in the native VS Code chat panel.
* Users can type "@productos deploy to staging" and get responses
* in the same UI as GitHub Copilot.
*/
// Chat response interface from Control Plane
interface ChatResponse {
message: string;
toolCalls?: { name: string; arguments: any }[];
runs?: any[];
finishReason: string;
}
/**
* Register the Product OS chat participant
*/
export function registerChatParticipant(context: vscode.ExtensionContext) {
// Create the chat participant
const participant = vscode.chat.createChatParticipant(
"productos.chat",
chatHandler
);
// Set participant properties
participant.iconPath = vscode.Uri.joinPath(context.extensionUri, "media", "icon.svg");
// Add follow-up provider for suggestions
participant.followupProvider = {
provideFollowups(
result: vscode.ChatResult,
context: vscode.ChatContext,
token: vscode.CancellationToken
): vscode.ProviderResult<vscode.ChatFollowup[]> {
// Suggest follow-up actions based on what was just done
return [
{ prompt: "Show me funnel analytics", label: "📊 Analytics" },
{ prompt: "Deploy to staging", label: "🚀 Deploy" },
{ prompt: "Generate marketing posts", label: "📣 Marketing" },
{ prompt: "What drives conversions?", label: "📈 Drivers" }
];
}
};
context.subscriptions.push(participant);
console.log("[Product OS] Chat participant @productos registered");
}
/**
* Handle chat requests
*/
async function chatHandler(
request: vscode.ChatRequest,
context: vscode.ChatContext,
response: vscode.ChatResponseStream,
token: vscode.CancellationToken
): Promise<vscode.ChatResult> {
const userPrompt = request.prompt;
console.log("[Product OS] Chat request:", userPrompt);
// Show progress
response.progress("Connecting to Product OS...");
try {
// Build message history from context
const messages = buildMessageHistory(context, userPrompt);
// Get code context if available
const codeContext = await getCodeContext();
// Call the Control Plane
const result = await callControlPlane(messages, codeContext);
// Handle tool calls
if (result.toolCalls && result.toolCalls.length > 0) {
for (const toolCall of result.toolCalls) {
response.markdown(`\n\n**🔧 Executing:** \`${toolCall.name}\`\n`);
}
}
// Handle runs (tool execution results)
if (result.runs && result.runs.length > 0) {
for (const run of result.runs) {
const status = run.status === "succeeded" ? "✅" : "❌";
response.markdown(`\n${status} **${run.tool}**\n`);
if (run.output) {
response.markdown("\n```json\n" + JSON.stringify(run.output, null, 2) + "\n```\n");
}
if (run.error) {
response.markdown(`\n**Error:** ${run.error.message}\n`);
}
}
}
// Stream the main response
if (result.message) {
response.markdown(result.message);
}
return { metadata: { command: "chat" } };
} catch (error: any) {
console.error("[Product OS] Chat error:", error);
response.markdown(`\n\n❌ **Error:** ${error.message}\n\n`);
response.markdown("Make sure the Control Plane is running at " + getBackendUrl());
return {
metadata: { command: "error" },
errorDetails: { message: error.message }
};
}
}
/**
* Build message history from chat context
*/
function buildMessageHistory(
context: vscode.ChatContext,
currentPrompt: string
): { role: string; content: string }[] {
const messages: { role: string; content: string }[] = [];
// Add previous messages from context
for (const turn of context.history) {
if (turn instanceof vscode.ChatRequestTurn) {
messages.push({ role: "user", content: turn.prompt });
} else if (turn instanceof vscode.ChatResponseTurn) {
// Extract text from response
let text = "";
for (const part of turn.response) {
if (part instanceof vscode.ChatResponseMarkdownPart) {
text += part.value.value;
}
}
if (text) {
messages.push({ role: "assistant", content: text });
}
}
}
// Add current prompt
messages.push({ role: "user", content: currentPrompt });
return messages;
}
/**
* Get context from the active editor
*/
async function getCodeContext(): Promise<any | undefined> {
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
}
};
}
// No selection - include some context from the current file
const document = editor.document;
const cursorLine = selection.active.line;
const startLine = Math.max(0, cursorLine - 20);
const endLine = Math.min(document.lineCount - 1, cursorLine + 20);
const range = new vscode.Range(startLine, 0, endLine, document.lineAt(endLine).text.length);
const surroundingCode = document.getText(range);
if (surroundingCode.trim()) {
return {
files: [{
path: vscode.workspace.asRelativePath(editor.document.uri),
content: surroundingCode
}]
};
}
return undefined;
}
/**
* Call the Control Plane chat endpoint
*/
async function callControlPlane(
messages: { role: string; content: string }[],
context?: any
): Promise<ChatResponse> {
const backendUrl = getBackendUrl();
const response = await fetch(`${backendUrl}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages,
context,
autoExecuteTools: true
})
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Control Plane error: ${response.status} - ${text}`);
}
return response.json();
}

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,176 @@
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 { showJson, openRun, showRunDocument } from "./ui";
import { registerChatParticipant } from "./chatParticipant";
export function activate(context: vscode.ExtensionContext) {
console.log("Product OS extension activated");
// Register @productos in the native VS Code chat
// This gives us the Copilot-like chat experience for FREE
try {
registerChatParticipant(context);
} catch (e) {
console.log("[Product OS] Chat Participant API not available (requires VS Code 1.90+)");
}
// Create tree providers
const toolsProvider = new ToolsTreeProvider();
const runsProvider = new RunsTreeProvider();
// 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

610
technical_spec.md Normal file
View File

@@ -0,0 +1,610 @@
Google Cloud Product OS
Technical Specification
Product-Centric IDE + SaaS Autopilot Platform
1. Purpose
This document defines the technical architecture, components, interfaces, and implementation plan for building a:
Google Cloudnative, Gemini-powered Product Operating System (Product OS)
The platform unifies:
Code development
Product launch
Marketing automation
Analytics and causality
Growth optimization
Support automation
Experimentation
Infrastructure management
into a single product-centric IDE and automation system.
This is not a general-purpose IDE.
It is a Product OS for launching and operating SaaS products on Google Cloud.
2. Core Design Principles
2.1 Product-Centric Orientation
The platform optimizes for:
Shipping products
Launching features
Running marketing
Optimizing growth
Operating infrastructure
Automating decisions
Not for:
Arbitrary coding workflows
Multi-cloud portability
Framework experimentation
2.2 Opinionated for Google Cloud
The platform is single-cloud and deeply integrated with:
Cloud Run
Cloud Build
Artifact Registry
Firestore
Cloud SQL
BigQuery
Pub/Sub
Vertex AI (Gemini)
No AWS or Azure abstraction layers are supported.
2.3 Backend Tool Execution (Security Model)
All automation executes on the backend.
The IDE:
Never runs gcloud
Never runs Terraform
Never holds GCP credentials
Never touches databases directly
Instead:
IDE / Supervisor AI
Control Plane API
Executors
GCP Services
2.4 AI as a Product Operator
The AI is not a coding assistant.
It is a:
Product Operator AI
Responsibilities:
Interpret product goals
Read analytics and insights
Decide actions
Dispatch tools
Enforce policies
Learn from outcomes
3. High-Level Architecture
┌─────────────────────────────┐
│ VSCodium IDE Client │
│ (Product-Centric UI Shell) │
└──────────────┬──────────────┘
┌──────────────────────────┐
│ Control Plane API │
│ (Tool Router + Policy) │
└──────────────┬───────────┘
┌──────────────┬───────────┼─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
Deploy Executor Analytics Exec Firestore Exec SQL Exec Missinglettr Exec
Cloud Build+Run BigQuery Firestore Cloud SQL Social Posting
┌──────▼───────┐
│ GCS Store │
│ Artifacts │
└──────────────┘
4. IDE Client Architecture
4.1 Base Editor
VSCodium distribution
OpenVSX marketplace
Preinstalled extensions
Preconfigured settings
Custom UI panels
4.2 Product-Centric Navigation
The IDE must expose:
Product OS
├── Code
├── Marketing
├── Analytics
├── Growth
├── Support
├── Experiments
└── Infrastructure
Each section is:
First-class
AI-assisted
Connected to backend tools
4.3 IDE Responsibilities
The IDE handles:
File editing
Patch preview & application
Project context collection
Tool invocation UI
Artifact viewing
Logs & traces display
The IDE does NOT:
Execute cloud commands
Store secrets
Perform deployments
Perform database queries
5. Control Plane API
5.1 Purpose
The Control Plane is the central orchestration backend.
Responsibilities:
Auth
Tool registry
Tool invocation routing
Policy enforcement
Run tracking
Artifact storage (GCS)
Gemini proxy
5.2 Core Endpoints
POST /tools/invoke
GET /runs/{run_id}
GET /runs/{run_id}/logs
GET /tools
GET /artifacts/{run_id}
5.3 Tool Invocation Contract
Request
{
"tool": "cloudrun.deploy_service",
"tenant_id": "t_123",
"workspace_id": "w_456",
"input": {
"service_name": "marketing-gateway",
"repo": "github.com/org/repo",
"ref": "main",
"env": "prod"
},
"dry_run": false
}
Response
{
"run_id": "run_20260119_abc",
"status": "queued"
}
6. Tool Registry
All executable actions are declared as tools.
6.1 Tool Schema
tools:
cloudrun.deploy_service:
description: Deploy a Cloud Run service
input_schema:
service_name: string
repo: string
ref: string
env: string
output_schema:
service_url: string
risk: medium
executor: deploy-executor
6.2 Registry Responsibilities
Input validation
Output validation
Risk classification
Executor routing
Used by:
IDE
Supervisor AI
Web dashboard
7. Executors (Domain Services)
Each executor is a Cloud Run service with its own service account.
7.1 Deploy Executor
Purpose:
Build and deploy services
Tools:
cloudrun.deploy_service
cloudrun.tail_logs
cloudrun.rollback
GCP APIs:
Cloud Build
Cloud Run
Artifact Registry
IAM:
roles/cloudbuild.builds.editor
roles/run.admin (scoped)
roles/artifactregistry.writer
7.2 Analytics Executor (OpsOS)
Purpose:
Product intelligence and causality
Tools:
analytics.get_funnel_summary
analytics.get_top_drivers
analytics.get_anomalies
GCP APIs:
BigQuery
BigQuery ML
IAM:
roles/bigquery.dataViewer
roles/bigquery.jobUser
7.3 Firestore Executor
Purpose:
Company Brain + configs
Tools:
firestore.get_company_brain
firestore.update_company_brain
GCP APIs:
Firestore
IAM:
roles/datastore.user
7.4 SQL Executor
Purpose:
Transactional summaries
Tools:
sql.get_subscription_summary
sql.get_user_metrics
GCP APIs:
Cloud SQL
IAM:
roles/cloudsql.client
DB-level users
7.5 Missinglettr Executor
Purpose:
Social publishing
Tools:
missinglettr.publish_campaign
missinglettr.get_campaign_status
Secrets:
Missinglettr API tokens
IAM:
roles/secretmanager.secretAccessor
8. Data Storage
8.1 Firestore
Used for:
Company Brain
Tool registry
Policy configs
Style profiles
Run metadata
8.2 GCS
Used for:
Logs
AI outputs
Generated patches
Deployment artifacts
Prompt snapshots
8.3 BigQuery
Used for:
Event warehouse
Funnels
Causality models
Experiment results
9. AI Integration
9.1 Gemini Proxy
All AI calls go through Control Plane.
Responsibilities:
Auth
Rate limiting
Prompt registry
Logging
Cost controls
9.2 AI Patch Contract
Gemini must return:
{
"files": [
{
"path": "src/main.ts",
"diff": "@@ -1,3 +1,6 @@ ..."
}
],
"commands": [
"npm test"
],
"summary": "Add logging middleware"
}
10. IAM Strategy
10.1 Users
OAuth only
No GCP IAM
No key files
10.2 Backend
Workload identity
No long-lived keys
Least privilege
Per-executor roles
11. Supported Languages
TypeScript / Node
Python
No additional languages in v1.
12. SaaS Autopilot Layer
A Supervisor AI Agent runs in Vertex AI Agent Designer.
It calls the same tools as the IDE.
Supervisor AI → Control Plane → Executors
13. Non-Goals
The platform does NOT:
Replace VS Code generically
Support all frameworks
Support multi-cloud
Allow raw IAM editing
Execute cloud commands locally
14. Repository Structure
/platform
/client-ide
/vscodium
/extensions
/backend
/control-plane
/executors
/contracts
/infra
/docs
15. Implementation Phases
Phase 1 Core
Control Plane API
Deploy Executor
Gemini Proxy
IDE Deploy UI
Phase 2 Intelligence
Firestore Executor
Analytics Executor
Funnel + driver tools
Phase 3 Automation
Missinglettr Executor
Growth + Experiments
Supervisor AI
16. Final Statement
This system is a:
Google Cloudnative Product Operating System
for launching, growing, and automating SaaS products
using Gemini and backend-controlled automation.
Optional Next Steps
Generate Control Plane API scaffold
Generate Tool Registry schema
Generate VSCodium extension skeleton
Generate Terraform base
If you want, I can next generate:
The Control Plane API OpenAPI spec
The Tool Registry schema file
The First Executor service skeleton
The VSCodium extension skeleton
Tell me which one you want first.

289
vision-ext.md Normal file
View File

@@ -0,0 +1,289 @@
Final Direction Summary: Replacing Cursor for Your Use Case
Core Goal
You want:
A Cursor-like chat experience
Integrated with:
your codebase
Google Cloud services
your product workflows
Without paying for Cursor or depending on OpenAI/Cursor infra.
We aligned on an approach that gives you this, while keeping costs, maintenance, and risk manageable.
The Chosen Architecture
1. Use VSCodium as your editor base
Instead of Cursor or VS Code:
Open-source
Redistributable
No telemetry/licensing issues
Compatible with VS Code extensions
Lets you ship your own IDE experience
You are not building a new editor, you are building a product cockpit on top of a proven editor shell.
2. Build your product experience as an Extension (not a fork)
We agreed:
Extension-first is the right V1 strategy.
Because with an extension you can:
Add your own Product OS UI
Build your own chat interface
Integrate Gemini + GCP + tools
Ship cross-platform quickly
Avoid the heavy maintenance cost of a fork
A fork only becomes justified later if you need:
Hard shell changes
Locked-down layouts
Enterprise kiosk behavior
3. Use an Open-Source Chat UI Instead of Cursor
To avoid building chat UI from scratch, we landed on:
✅ Best starting point: Open-source chat extensions
You can reuse or extend:
Option A (Recommended)
Copilot Chat UI (open-sourced by Microsoft)
Production-grade chat UI
MIT license
Can be repointed to:
your backend
Gemini / Vertex AI
Gives you:
streaming responses
history
context-aware UX
Option B (Fast prototyping)
Continue
Open-source
Already works in VSCodium
Can connect to:
local LLMs
remote APIs (your Gemini backend)
Great for validating UX quickly
This gives you:
A Cursor-like chat UX without Cursor.
4. Gemini + Control Plane replaces Cursors backend
Instead of:
Cursor → OpenAI → Cursor tools
You will have:
VSCodium → Your Extension → Control Plane → Gemini (Vertex AI) + GCP Tools
Your backend becomes the intelligence layer:
/chat endpoint → Gemini
/tools/invoke → deploy, logs, analytics, campaigns, etc
policy enforcement
cost tracking
product-aware reasoning
This gives you:
full ownership
no vendor lock-in
better monetization control
5. Code Generation Does NOT require rebuilding everything
We clarified:
You do NOT need to rebuild a full editor or execution engine to generate code.
You only need:
Minimal tooling:
Model returns:
structured diffs
optional commands
Extension:
previews changes
applies patches
optionally runs tests
Everything else (editing, git, terminals) is already provided by VSCodium.
So you get:
Cursor-like “generate code and apply it” behavior
without building Cursor from scratch.
6. Direct Cloud Access: Use Signed URLs, Not Service Accounts
We aligned on:
Dont give the IDE persistent cloud credentials
Use:
Control Plane → signed URLs → GCS
This gives you:
better security
easier monetization
easy migration later
avoids long-term risk
You can still have:
Direct data transfer
without exposing cloud identities.
7. Product OS > Code Chat Only
Youre not just building a “code helper chat”.
Youre building a Product OS, where chat can:
generate code
deploy services
analyze funnels
generate campaigns
summarize experiments
optimize onboarding
respond to support tickets
Thats your differentiator over Cursor:
Cursor is a coding assistant
Youre building a product automation cockpit
What This Means Practically
You will:
Run VSCodium
Install:
Your Product OS extension
An open-source chat UI (or embed it)
Connect it to:
Your Control Plane
Gemini on Vertex AI
Add:
Tool invocation
Product modules (marketing, analytics, growth, etc)
Ship:
A Cursor-free AI IDE focused on launching and running products
What You Avoid
By this approach, you avoid:
Paying Cursor per seat
Being locked into OpenAI routing
Forking VS Code prematurely
Owning an editor platform too early
Maintaining a custom compiler/distribution pipeline
Final Position
You do not need Cursor.
You can build:
A great chat interface
With code + GCP integration
On VSCodium
With open-source UI
Powered by Gemini
And fully controlled by you
If youd like, next I can:
Lay out a concrete build roadmap (V1 → V3)
Or give you a minimal stack diagram + repo layout
Or produce a starter technical spec for your Product OS Chat + Tooling platform