diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b489e2..d016164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index a3a44f5..1d10450 100644 --- a/README.md +++ b/README.md @@ -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__` 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`. diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 1e09fc3..53b4355 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -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("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)} /> @@ -1716,6 +1719,7 @@ export default function App() { onDeleteProject={deleteActiveProject} onSelectProject={setSelectedProjectID} onNavigateToDashboard={() => setActiveView("dashboard")} + onAgentSetup={() => setAgentSetupOpen(true)} /> @@ -1966,6 +1970,23 @@ export default function App() { isOpen={showOnboarding()} projectName={activeProject()?.name ?? "Your project"} onClose={() => setShowOnboarding(false)} + onDone={() => { + setShowOnboarding(false); + setAgentSetupOpen(true); + }} + /> + + setAgentSetupOpen(false)} + project={activeProject()} + canManage={canUpdateProject() || isDemo} + demoMode={isDemo} + onKeyCreated={() => { + void refetchAPIKeys(); + void refetchAuditLogs(); + void refreshProjectOverviewSnapshot(); + }} /> ); diff --git a/apps/frontend/src/components/AgentSetupModal.tsx b/apps/frontend/src/components/AgentSetupModal.tsx new file mode 100644 index 0000000..e0ecd52 --- /dev/null +++ b/apps/frontend/src/components/AgentSetupModal.tsx @@ -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 ( + + + Generating credentials requires the project admin role. Ask a project + admin to open this dialog and copy the prompt for you. +

+ } + > + + + Creating a write-scoped API key… + + + + + + } + > +
+
+ Fresh key {keyPrefix()}… minted — shown only inside this prompt.{" "} + It grants write access to this project; revoke it under Settings → API keys if the + prompt leaks or the agent finishes. +
+
+
+            {prompt()}
+          
+ + +
+ + +
+
+
+
+
+ ); +} diff --git a/apps/frontend/src/components/OnboardingModal.tsx b/apps/frontend/src/components/OnboardingModal.tsx index 225a935..01d478f 100644 --- a/apps/frontend/src/components/OnboardingModal.tsx +++ b/apps/frontend/src/components/OnboardingModal.tsx @@ -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) { diff --git a/apps/frontend/src/components/ProjectDashboard.tsx b/apps/frontend/src/components/ProjectDashboard.tsx index bc46dc9..507d6f4 100644 --- a/apps/frontend/src/components/ProjectDashboard.tsx +++ b/apps/frontend/src/components/ProjectDashboard.tsx @@ -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) {

+ + +
+ + +