Merge pull request #32 from Dvorinka/feat/agent-setup-prompt

feat: agent setup prompt for new projects
This commit is contained in:
Tomáš Dvořák authored and GitHub committed 2026-09-28 11:32:58 +02:00
commit aab4dd3bfd
10 files changed
+381 -2

No files matched your search

+1
View File
@@ -6,6 +6,7 @@ All notable changes to Primora. Format follows [Keep a Changelog](https://keepac
### Added
- **Agent setup prompt** — a "Set up with an AI agent" dialog that mints a fresh `write`-scoped API key (ingest + reads + non-admin mutations, never key-minting) and renders a copyable prompt handing a coding agent everything it needs: the deployment endpoint, project id, the key itself, plus tasks to instrument error/metric/log/heartbeat reporting per component (`POST /api/v1/ingest` — works from any language), register the app's databases via `db-connections` (postgres → cassandra), wire the MCP server / CLI for future agents, and verify with a test event + deploy marker. Opens at the end of the new-project onboarding and is re-reachable from the project dashboard and Projects page; keys are shown only inside the prompt (revoke under Settings → API keys).
- **Local secrets vault in the CLI** (`@primora/cli` 0.6.0) — `primora vault init/status` and `primora secrets set|get|list|rm|import` manage an encrypted `~/.config/primora/vault.bin` (Argon2id 64 MiB/3/4 → XChaCha20-Poly1305, header bound as AEAD AAD, atomic writes). Entries carry value + optional `url`/`notes` metadata. Password via `PRIMORA_VAULT_PASSWORD`, `--password-file`, or hidden prompt; file path overridable with `PRIMORA_VAULT`. Clean-room format — no external vault compatibility.
- **Vault sessions** — `primora vault unlock --ttl 900` proves the password once and writes a TTL'd `vault.session` (0600); agents and scripts use the vault without the password. `primora vault lock` revokes immediately.
- **`primora agent` credential broker** — wraps a child process with dummy tokens and a loopback base URL; the proxy attaches the real secret on the wire per grant (TTL, cap 3600 s). Presets: `anthropic`, `openai`, `openrouter`, `groq`, `deepseek`, `xai`, `github`, `npm`; `--upstream name=https://…` covers arbitrary Bearer APIs. Base-URL broker, not TLS MITM — tools must honor a base-URL override.
+1
View File
@@ -55,6 +55,7 @@ Supabase, with no hosted tier - your data stays on your hardware.
- **Automation** - scheduled jobs, inbound webhooks, telemetry alert rules, and `secret://` refs resolved at delivery.
- **Realtime** - SSE event stream with a typed client SDK (`@primora/client`) — channel subscriptions, presence counts, and client-published `custom.*` events that fan out to webhooks and functions.
- **API keys** - `prm_<prefix>_<secret>` credentials, shown once.
- **Agent setup** - every project can mint a `write`-scoped key and generate a copyable AI-agent prompt that wires telemetry, database tracking, and MCP/CLI access into any repository.
- **Audit log** - every mutating request recorded, with CSV/JSON export.
- **Demo mode** - fully client-side seeded workspace via `?demo=true`.
+21
View File
@@ -29,6 +29,7 @@ import { authClient, fetchApiToken } from "./lib/auth-client";
import { configureApiToken, errorMessage } from "./lib/api";
import { isDemoMode, disableDemoMode, enableDemoMode, demoSession, demoService } from "./lib/demo-mode";
import {
AgentSetupModal,
AppShell,
CommandPaletteEnhanced,
NetworkError,
@@ -127,6 +128,7 @@ export default function App() {
const [name, setName] = createSignal("");
const [activeView, setActiveView] = createSignal<ViewType>("dashboard");
const [showOnboarding, setShowOnboarding] = createSignal(false);
const [agentSetupOpen, setAgentSetupOpen] = createSignal(false);
const [paletteOpen, setPaletteOpen] = createSignal(false);
const [theme, setTheme] = createSignal<"dark" | "light">(
(localStorage.getItem("primora_theme") as "dark" | "light") ?? "dark",
@@ -1696,6 +1698,7 @@ export default function App() {
overview={projectOverview()}
recentAudit={auditLogs()}
onNavigate={(view) => setActiveView(view as ViewType)}
onAgentSetup={() => setAgentSetupOpen(true)}
/>
</Show>
</Show>
@@ -1716,6 +1719,7 @@ export default function App() {
onDeleteProject={deleteActiveProject}
onSelectProject={setSelectedProjectID}
onNavigateToDashboard={() => setActiveView("dashboard")}
onAgentSetup={() => setAgentSetupOpen(true)}
/>
</Show>
@@ -1966,6 +1970,23 @@ export default function App() {
isOpen={showOnboarding()}
projectName={activeProject()?.name ?? "Your project"}
onClose={() => setShowOnboarding(false)}
onDone={() => {
setShowOnboarding(false);
setAgentSetupOpen(true);
}}
/>
<AgentSetupModal
open={agentSetupOpen()}
onClose={() => setAgentSetupOpen(false)}
project={activeProject()}
canManage={canUpdateProject() || isDemo}
demoMode={isDemo}
onKeyCreated={() => {
void refetchAPIKeys();
void refetchAuditLogs();
void refreshProjectOverviewSnapshot();
}}
/>
</Show>
);
@@ -0,0 +1,156 @@
import { Show, createEffect, createSignal } from "solid-js";
import { ProjectsService } from "@primora/api-client";
import { demoService } from "../lib/demo-mode";
import { buildAgentPrompt } from "../lib/agent-prompt";
import { errorMessage } from "../lib/api";
import { Modal, ModalFooter } from "./Modal";
import { IconCheck, IconCopy, IconKey, IconRefresh } from "./Icons";
export interface AgentSetupProject {
id: string;
name: string;
slug: string;
}
interface AgentSetupModalProps {
open: boolean;
onClose: () => void;
project?: AgentSetupProject;
canManage: boolean;
demoMode: boolean;
/** called after a key is minted so the app can refetch keys/audit/overview */
onKeyCreated?: () => void;
}
export function AgentSetupModal(props: AgentSetupModalProps) {
const [prompt, setPrompt] = createSignal("");
const [keyPrefix, setKeyPrefix] = createSignal("");
const [busy, setBusy] = createSignal(false);
const [error, setError] = createSignal("");
const [copied, setCopied] = createSignal(false);
const generate = async () => {
const project = props.project;
if (!project || busy()) return;
setBusy(true);
setError("");
setCopied(false);
try {
const stamp = new Date().toISOString().slice(0, 16).replace(/[:T]/g, "");
const result = props.demoMode
? await demoService.createApiKey({ requestBody: { name: `agent-setup-${stamp}` } })
: await ProjectsService.createApiKey({
projectId: project.id,
requestBody: { name: `agent-setup-${stamp}`, scopes: ["write"] },
});
setKeyPrefix(result.prefix);
setPrompt(
buildAgentPrompt({
baseUrl: window.location.origin,
projectId: project.id,
projectName: project.name,
projectSlug: project.slug,
apiKey: result.secret,
}),
);
props.onKeyCreated?.();
} catch (e) {
setError(errorMessage(e, "Could not create an API key"));
} finally {
setBusy(false);
}
};
// Mint a fresh write-scoped key each time the modal opens — secrets are
// shown once, so a cached prompt would carry a dead credential. The error
// guard prevents a retry loop when minting fails; the user retries manually.
createEffect(() => {
if (props.open && props.project && props.canManage && !prompt() && !busy() && !error()) {
void generate();
}
if (!props.open) {
setPrompt("");
setKeyPrefix("");
setError("");
setCopied(false);
}
});
const copyPrompt = async () => {
try {
await navigator.clipboard.writeText(prompt());
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* clipboard unavailable — the text is selectable instead */
}
};
return (
<Modal
open={props.open}
onClose={props.onClose}
title="Set up with an AI agent"
description="Paste this prompt into a coding agent (Claude Code, Cursor, Devin…) working in your repository. It instruments monitoring, registers your databases, and connects agent tooling — end to end."
size="xl"
error={error()}
>
<Show
when={props.canManage}
fallback={
<p class="text-sm text-text-2">
Generating credentials requires the project <strong>admin</strong> role. Ask a project
admin to open this dialog and copy the prompt for you.
</p>
}
>
<Show
when={prompt()}
fallback={
<div class="flex items-center gap-3 py-8 justify-center text-sm text-text-2">
<Show when={busy()}>
<span class="spinner" /> Creating a write-scoped API key…
</Show>
<Show when={!busy() && error()}>
<button class="btn btn-secondary btn-sm" onClick={() => void generate()}>
Try again
</button>
</Show>
</div>
}
>
<div class="message message-warning mb-3">
<div class="flex-1 min-w-0">
<strong>Fresh key {keyPrefix()}… minted — shown only inside this prompt.</strong>{" "}
It grants write access to this project; revoke it under Settings → API keys if the
prompt leaks or the agent finishes.
</div>
</div>
<pre
class="text-xs rounded-lg border border-border bg-surface-2 p-4 overflow-y-auto"
style="max-height: 22rem; white-space: pre-wrap; word-break: break-word"
>
{prompt()}
</pre>
<ModalFooter class="px-0 pb-0 pt-4" align="between">
<button class="btn btn-ghost btn-sm" onClick={() => void generate()} disabled={busy()}>
<IconRefresh class="w-4 h-4" />
New key &amp; prompt
</button>
<div class="flex gap-2">
<button class="btn btn-secondary" onClick={props.onClose}>
<IconKey class="w-4 h-4" />
Done
</button>
<button class="btn btn-primary" onClick={() => void copyPrompt()}>
<Show when={!copied()} fallback={<><IconCheck class="w-4 h-4" /> Copied</>}>
<IconCopy class="w-4 h-4" /> Copy prompt
</Show>
</button>
</div>
</ModalFooter>
</Show>
</Show>
</Modal>
);
}
@@ -6,6 +6,8 @@ interface OnboardingModalProps {
isOpen: boolean;
projectName: string;
onClose: () => void;
/** fired on the final "Done" step instead of onClose — used to chain into agent setup */
onDone?: () => void;
}
const steps = [
@@ -88,7 +90,16 @@ export function OnboardingModal(props: OnboardingModalProps) {
</Show>
<button
class="btn btn-primary btn-sm"
onClick={() => (step() < steps.length - 1 ? setStep(step() + 1) : handleClose())}
onClick={() => {
if (step() < steps.length - 1) {
setStep(step() + 1);
} else if (props.onDone) {
setStep(0);
props.onDone();
} else {
handleClose();
}
}}
>
{step() === steps.length - 1 ? "Done" : "Next"}
</button>
@@ -8,6 +8,7 @@ import {
IconCollections,
IconIntegrations,
IconChevronRight,
IconTerminal,
} from "./Icons";
interface ProjectDashboardProps {
@@ -15,6 +16,7 @@ interface ProjectDashboardProps {
overview?: ProjectOverview;
recentAudit?: AuditLog[];
onNavigate: (view: string) => void;
onAgentSetup?: () => void;
}
function formatBytes(bytes?: number): string {
@@ -135,6 +137,12 @@ export function ProjectDashboard(props: ProjectDashboardProps) {
</p>
</div>
<div class="page-actions">
<Show when={props.onAgentSetup}>
<button class="btn btn-secondary btn-sm" onClick={() => props.onAgentSetup!()}>
<IconTerminal class="w-3.5 h-3.5" />
Agent setup
</button>
</Show>
<button class="btn btn-secondary btn-sm" onClick={() => props.onNavigate("settings")}>
<IconKey class="w-3.5 h-3.5" />
Keys
+1
View File
@@ -1,4 +1,5 @@
// Components barrel export
export { AgentSetupModal, type AgentSetupProject } from "./AgentSetupModal";
export { AppShell, type ViewType } from "./AppShell";
export { Badge, StatusBadge } from "./Badge";
export { Button } from "./Button";
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { buildAgentPrompt } from "../agent-prompt";
const base = {
baseUrl: "https://primora.example.com",
projectId: "proj-123",
projectName: "Production API",
projectSlug: "production-api",
apiKey: "prm_abcd_secretkey",
};
describe("buildAgentPrompt", () => {
const prompt = buildAgentPrompt(base);
it("embeds credentials verbatim", () => {
expect(prompt).toContain(`PRIMORA_BASE_URL=${base.baseUrl}`);
expect(prompt).toContain(`PRIMORA_API_KEY=${base.apiKey}`);
expect(prompt).toContain(`PRIMORA_PROJECT=${base.projectId}`);
});
it("points ingest at the deployment's API base", () => {
expect(prompt).toContain(`POST ${base.baseUrl}/api/v1/ingest`);
expect(prompt).not.toContain(`${base.baseUrl}/api/v1/api/v1`);
});
it("covers monitoring, databases and agent access", () => {
expect(prompt).toContain("/db-connections");
expect(prompt).toContain("deploy-markers");
expect(prompt).toContain("PRIMORA_PROJECT");
for (const t of ["postgres", "mysql", "mongodb", "redis"]) {
expect(prompt).toContain(t);
}
});
it("never fabricates database credentials", () => {
expect(prompt).toMatch(/never fabricate credentials/i);
});
it("trims a trailing slash on the base URL", () => {
const p = buildAgentPrompt({ ...base, baseUrl: "http://localhost/" });
expect(p).toContain("PRIMORA_BASE_URL=http://localhost\n");
expect(p).toContain("http://localhost/api/v1/ingest");
});
it("includes a concrete MCP stdio block when a source path is given", () => {
const p = buildAgentPrompt({ ...base, primoraSourcePath: "/opt/Primora" });
expect(p).toContain("/opt/Primora/apps/mcp/dist/server.js");
expect(p).toContain('"mcpServers"');
});
});
+122
View File
@@ -0,0 +1,122 @@
/**
* Builds the copy-paste prompt that hands an AI coding agent everything it
* needs to wire a repository into a Primora project: telemetry ingest,
* database connections, and agent-facing access (MCP/CLI). Pure function so
* it can be unit-tested and reused by any surface.
*/
export interface AgentPromptInput {
/** Deployment origin, e.g. http://localhost — /api/v1 is appended here. */
baseUrl: string;
projectId: string;
projectName: string;
projectSlug: string;
/** Full prm_… key shown once at mint time. */
apiKey: string;
/** Optional absolute path to a local Primora source checkout (enables the MCP build step). */
primoraSourcePath?: string;
}
export function buildAgentPrompt(input: AgentPromptInput): string {
const base = input.baseUrl.replace(/\/+$/, "");
const api = `${base}/api/v1`;
const p = input.projectId;
const mcpSection = input.primoraSourcePath
? `A Primora source checkout exists at \`${input.primoraSourcePath}\`. Build the MCP server once:
\`\`\`bash
cd ${input.primoraSourcePath} && npm run build --workspace @primora/mcp # → apps/mcp/dist/server.js
\`\`\`
Then register a stdio MCP server named \`primora\` in your client's MCP config:
\`\`\`json
{
"mcpServers": {
"primora": {
"command": "node",
"args": ["${input.primoraSourcePath}/apps/mcp/dist/server.js"],
"env": {
"PRIMORA_BASE_URL": "${base}",
"PRIMORA_API_KEY": "$PRIMORA_API_KEY",
"PRIMORA_PROJECT": "${p}"
}
}
}
}
\`\`\`
This exposes 30+ \`primora_*\` tools (telemetry query, collections, storage, jobs, audit) to every agent that loads the config.`
: `If a Primora source checkout is available on this machine, build \`@primora/mcp\` and register it as a stdio MCP server with env \`PRIMORA_BASE_URL=${base}\`, \`PRIMORA_API_KEY\`, \`PRIMORA_PROJECT=${p}\`. Otherwise skip — the REST API above covers the same surface.`;
return `You are an AI agent. Wire this repository into the Primora project "${input.projectName}" so it reports telemetry, tracks its databases, and is reachable by future agents. Work through every section; do not stop after the first.
## Credentials
\`\`\`bash
PRIMORA_BASE_URL=${base}
PRIMORA_API_KEY=${input.apiKey}
PRIMORA_PROJECT=${p}
\`\`\`
- API base: \`${api}\`
- Send the key as header \`X-Primora-Key\` (alias \`X-API-Key\` also accepted).
- Key scope is \`write\`: it covers telemetry ingest, reads, and non-admin mutations (connections, buckets, collections, webhooks). It cannot mint keys or manage members — that is intentional.
- Put these in the project's \`.env\` (add to \`.env.example\` with placeholders) and reference them via env vars in code. Never hardcode or commit the literal key.
## 1. Monitoring — instrument every component
All telemetry goes to one endpoint; components self-register on first event:
\`\`\`
POST ${api}/ingest
X-Primora-Key: $PRIMORA_API_KEY
Content-Type: application/json
{"events":[{"type":"error","component":"${input.projectSlug}-api","kind":"backend","severity":"error","message":"…","payload":{"stack":"…"},"ts":"<RFC3339>"}]}
\`\`\`
- \`type\`: \`error\` | \`metric\` | \`log\` | \`heartbeat\` | \`event\`. Batches up to 500 events.
- \`kind\`: \`frontend\` | \`backend\` | \`database\` | \`web\` | \`android\` | \`desktop\` | \`other\`.
- Errors with the same \`message\` (or explicit \`fingerprint\`) group into Issues.
Detect this repo's stack and instrument each deployable unit — frontend, backend, workers, landing page, all of them:
- **Browser / React / any web frontend**: add a small telemetry module that batches to /ingest (fetch + \`keepalive: true\`), hooks \`window.onerror\` + \`unhandledrejection\`, and flushes on \`visibilitychange\`. Component kind \`frontend\` or \`web\`.
- **Node / TypeScript backends**: hook the framework's error middleware and \`process.on("uncaughtException")\`; send \`heartbeat "up"\` every 30s. Kind \`backend\`.
- **Go / Python / Rust / other**: same HTTP contract — wrap panic/recover or global exception handlers and a periodic heartbeat. Any language works; there is no required SDK.
- Name components \`${input.projectSlug}-frontend\`, \`${input.projectSlug}-api\`, \`${input.projectSlug}-worker\`, etc.
Verify now: send one test event and confirm it lands (see §4).
## 2. Databases — register what this app uses
Ask the user for the connection details of each database this project uses — never fabricate credentials, never commit them. Register each one so it appears under Primora → Databases (schema browser, query console):
\`\`\`
POST ${api}/projects/${p}/db-connections
X-Primora-Key: $PRIMORA_API_KEY
{"name":"primary","db_type":"postgres","host":"…","port":5432,"database":"…","username":"…","password":"…","ssl":true}
\`\`\`
Supported \`db_type\`: postgres, mysql, sqlite, redis, mongodb, sqlserver, duckdb, clickhouse, cassandra. After creating each connection verify it:
\`\`\`
POST ${api}/projects/${p}/db-connections/{connectionID}/test → {"ok":true|false,"error":"…"}
\`\`\`
Report per-database status to the user. If a database is unreachable, say so — do not silently skip it.
## 3. Agent access — let future agents manage this project
${mcpSection}
Optional CLI path: \`primora login --api-key $PRIMORA_API_KEY\`, then \`primora use\`. For wrapping third-party agents with brokered credentials (the agent never holds real keys), use \`primora agent --<provider> <vault-secret> -- <cmd>\`.
## 4. Prove it works
1. \`curl -sS -X POST ${api}/ingest -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"events":[{"type":"event","component":"setup","message":"primora.setup.done"}]}'\` — expect \`{"accepted":1}\`.
2. Record a deploy marker: \`curl -sS -X POST ${api}/projects/${p}/deploy-markers -H "X-Primora-Key: $PRIMORA_API_KEY" -H "Content-Type: application/json" -d '{"ref":"<git sha>","environment":"dev","note":"agent setup"}'\`.
3. Finish with a short summary: components instrumented, database connections tested, and where the data lands (${base} → Telemetry, Databases, Integrations).
`;
}
+8 -1
View File
@@ -3,7 +3,7 @@ import type { ProjectSummary } from "@primora/api-client";
import { Badge } from "../components/Badge";
import { Modal, ModalFooter } from "../components/Modal";
import { Input, Textarea } from "../components/Input";
import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash } from "../components/Icons";
import { IconPlus, IconProjects, IconArrowRight, IconEdit, IconTrash, IconTerminal } from "../components/Icons";
interface ProjectInput {
name: string;
@@ -32,6 +32,7 @@ interface ProjectsPageProps {
onDeleteProject: () => void;
onSelectProject: (id: string) => void;
onNavigateToDashboard: () => void;
onAgentSetup?: () => void;
}
export function ProjectsPage(props: ProjectsPageProps) {
@@ -169,6 +170,12 @@ export function ProjectsPage(props: ProjectsPageProps) {
</span>
</div>
<div class="flex gap-2">
<Show when={props.onAgentSetup}>
<button class="btn btn-secondary" onClick={() => props.onAgentSetup!()}>
<IconTerminal class="w-4 h-4" />
Agent setup
</button>
</Show>
<button
class="btn btn-secondary"
onClick={() => setSettingsOpen(true)}