feat(web): browser-based prompt + personality editor (Stage 5)

New workspace package @mcpctl/web — a Vite + React 19 SPA that talks
to mcpd's existing HTTP API. Bundles to a static dist/ which Stage 6
will bake into the RPM and serve from mcpd at /ui via @fastify/static.

Pages:
  /ui/projects                       list projects
  /ui/projects/:name/prompts         CRUD project prompts (Monaco editor)
  /ui/agents                         list agents
  /ui/agents/:name                   tabs: Direct prompts | Personalities
  /ui/personalities/:id              bind/unbind prompts to a personality

Auth: paste a session token (mcpctl auth login) or PAT (mcpctl_pat_*)
once on a login screen, kept in localStorage; logout clears it.

API client: 60-line fetch wrapper, attaches the bearer header from
storage, throws an ApiError with status + parsed body on non-2xx.
A 200-line useFetch hook provides loading/error/data without a
state-management library — we are not building Notion.

UX:
  - Dark terminal-adjacent theme so the page feels like the CLI.
  - Monaco @monaco-editor/react for prompt content (markdown mode,
    word-wrap, search, multi-cursor).
  - Personality detail's "attach prompt" picker filters in-scope
    candidates: agent-direct + same-project + globals.

Dev loop:  pnpm --filter @mcpctl/web dev   (vite at :5173, proxies
  /api to https://mcpctl.ad.itaz.eu — override with MCPCTL_API_URL).
Build:     pnpm --filter @mcpctl/web build → src/web/dist/.

Tests: 7 vitest cases covering the bearer header / 4xx body / 204
no-content path on the api wrapper, and the login storage round-trip
+ help toggle. Production build green: 269 KB JS / 84 KB gzipped.
Typecheck clean (TS strict + exactOptionalPropertyTypes carried over).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-04-26 19:41:57 +01:00
parent 9050918a83
commit 0010cc18b7
21 changed files with 2539 additions and 5 deletions

4
src/web/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
dist
node_modules
.vite
*.log

21
src/web/index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>mcpctl — prompt editor</title>
<style>
:root { color-scheme: dark; }
html, body, #root { height: 100%; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #c9d1d9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Inter", sans-serif;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

28
src/web/package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "@mcpctl/web",
"version": "0.0.1",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run"
},
"dependencies": {
"@monaco-editor/react": "^4.7.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.7.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.7.0",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.1.0",
"jsdom": "^28.0.0",
"vite": "^7.2.0"
}
}

42
src/web/src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import * as React from 'react';
import { useState, useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { getToken } from './api';
import { Layout } from './components/Layout';
import { Login } from './components/Login';
import { ProjectsPage } from './pages/Projects';
import { ProjectPromptsPage } from './pages/ProjectPrompts';
import { AgentsPage } from './pages/Agents';
import { AgentDetailPage } from './pages/AgentDetail';
import { PersonalityDetailPage } from './pages/PersonalityDetail';
export function App(): React.JSX.Element {
const [tokenPresent, setTokenPresent] = useState(getToken() !== null);
// Listen for storage changes from other tabs.
useEffect(() => {
const onStorage = (): void => setTokenPresent(getToken() !== null);
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
if (!tokenPresent) {
return <Login onLogin={() => setTokenPresent(true)} />;
}
return (
<BrowserRouter basename="/ui">
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Navigate to="/projects" replace />} />
<Route path="/projects" element={<ProjectsPage />} />
<Route path="/projects/:name/prompts" element={<ProjectPromptsPage />} />
<Route path="/agents" element={<AgentsPage />} />
<Route path="/agents/:name" element={<AgentDetailPage />} />
<Route path="/personalities/:id" element={<PersonalityDetailPage />} />
<Route path="*" element={<Navigate to="/projects" replace />} />
</Route>
</Routes>
</BrowserRouter>
);
}

103
src/web/src/api.ts Normal file
View File

@@ -0,0 +1,103 @@
/**
* Thin fetch wrapper over mcpd's HTTP API.
*
* Reads the bearer token from localStorage on every request — the user can
* paste either a session token (from `mcpctl auth login`) or a PAT
* (`mcpctl_pat_*`). Both flow through the same `Authorization: Bearer …`
* header that mcpd already accepts (see `src/mcpd/src/middleware/auth.ts`).
*
* The wrapper deliberately stays minimal — no caching, no retry policy, no
* cancellation tokens. Add those when a real call site needs them.
*/
const TOKEN_KEY = 'mcpctl.token';
export function getToken(): string | null {
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY);
}
export interface ApiError extends Error {
status: number;
body: unknown;
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const token = getToken();
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (token !== null) headers['Authorization'] = `Bearer ${token}`;
const init: RequestInit = { method, headers };
if (body !== undefined) init.body = JSON.stringify(body);
const res = await fetch(path, init);
if (!res.ok) {
let parsed: unknown = null;
try { parsed = await res.json(); } catch { /* ignore */ }
const err = new Error(`HTTP ${String(res.status)} ${res.statusText}`) as ApiError;
err.status = res.status;
err.body = parsed;
throw err;
}
if (res.status === 204) return undefined as T;
return res.json() as Promise<T>;
}
export const api = {
get: <T>(path: string): Promise<T> => request<T>('GET', path),
post: <T>(path: string, body: unknown): Promise<T> => request<T>('POST', path, body),
put: <T>(path: string, body: unknown): Promise<T> => request<T>('PUT', path, body),
delete: <T = void>(path: string): Promise<T> => request<T>('DELETE', path),
};
// ── Domain types (subset of what the UI needs from mcpd) ──
export interface Project {
id: string;
name: string;
description?: string;
}
export interface Agent {
id: string;
name: string;
description: string;
systemPrompt: string;
llm: { id: string; name: string };
project: { id: string; name: string } | null;
defaultPersonality: { id: string; name: string } | null;
}
export interface Prompt {
id: string;
name: string;
content: string;
projectId: string | null;
agentId: string | null;
priority: number;
linkTarget: string | null;
}
export interface Personality {
id: string;
name: string;
description: string;
agentId: string;
agentName: string;
priority: number;
promptCount: number;
}
export interface PersonalityPrompt {
promptId: string;
promptName: string;
promptContent: string;
priority: number;
}

View File

@@ -0,0 +1,80 @@
import * as React from 'react';
import { NavLink, Outlet } from 'react-router-dom';
import { clearToken } from '../api';
/**
* Top-of-page nav + outlet. Terminal-style dark theme so the UI feels
* adjacent to the CLI rather than a separate product.
*/
export function Layout(): React.JSX.Element {
return (
<div style={styles.shell}>
<header style={styles.header}>
<div style={styles.brand}>mcpctl <span style={styles.dim}>· prompt editor</span></div>
<nav style={styles.nav}>
<NavLink to="/projects" style={navStyle}>Projects</NavLink>
<NavLink to="/agents" style={navStyle}>Agents</NavLink>
<button
style={styles.logout}
onClick={() => { clearToken(); window.location.assign('/ui/'); }}
>
Logout
</button>
</nav>
</header>
<main style={styles.main}>
<Outlet />
</main>
</div>
);
}
function navStyle({ isActive }: { isActive: boolean }): React.CSSProperties {
return {
color: isActive ? '#58a6ff' : '#c9d1d9',
textDecoration: 'none',
padding: '6px 12px',
borderBottom: isActive ? '2px solid #58a6ff' : '2px solid transparent',
};
}
const styles: Record<string, React.CSSProperties> = {
shell: {
minHeight: '100vh',
display: 'flex',
flexDirection: 'column',
},
header: {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 24px',
background: '#161b22',
borderBottom: '1px solid #30363d',
},
brand: {
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontWeight: 700,
fontSize: 16,
},
dim: { color: '#7d8590', fontWeight: 400 },
nav: {
display: 'flex',
gap: 8,
alignItems: 'center',
},
logout: {
background: 'transparent',
color: '#c9d1d9',
border: '1px solid #30363d',
padding: '4px 12px',
borderRadius: 4,
cursor: 'pointer',
marginLeft: 12,
},
main: {
flex: 1,
padding: 24,
overflowY: 'auto',
},
};

View File

@@ -0,0 +1,120 @@
import * as React from 'react';
import { useState } from 'react';
import { setToken } from '../api';
/**
* Login screen — paste a session token (`mcpctl auth login` writes one to
* ~/.mcpctl/credentials.json) or a PAT (`mcpctl_pat_*`). The token lives in
* localStorage; logout wipes it. We don't validate the token shape here —
* the first API call will 401 if it's wrong, and the user re-enters.
*/
export function Login({ onLogin }: { onLogin: () => void }): React.JSX.Element {
const [value, setValue] = useState('');
const [showHelp, setShowHelp] = useState(false);
function submit(e: React.FormEvent): void {
e.preventDefault();
if (value.trim() === '') return;
setToken(value.trim());
onLogin();
}
return (
<div style={styles.shell}>
<form style={styles.card} onSubmit={submit}>
<h1 style={styles.title}>mcpctl <span style={styles.dim}>prompt editor</span></h1>
<p style={styles.hint}>Paste a session token or PAT.</p>
<input
type="password"
autoFocus
placeholder="mcpctl_pat_… or session token"
value={value}
onChange={(e) => setValue(e.target.value)}
style={styles.input}
/>
<button type="submit" style={styles.button}>Continue</button>
<button
type="button"
style={styles.linkButton}
onClick={() => setShowHelp((s) => !s)}
>
{showHelp ? 'Hide help' : 'Where do I get a token?'}
</button>
{showHelp && (
<pre style={styles.help}>
{`# Use your interactive session token (writes to ~/.mcpctl/credentials.json)
mcpctl auth login
cat ~/.mcpctl/credentials.json # field: \`token\`
# Or mint a PAT for a specific project (longer-lived)
mcpctl create mcptoken my-editor --project my-project
`}
</pre>
)}
</form>
</div>
);
}
const styles: Record<string, React.CSSProperties> = {
shell: {
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
card: {
width: '100%',
maxWidth: 420,
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 6,
padding: 32,
display: 'flex',
flexDirection: 'column',
gap: 12,
},
title: {
margin: 0,
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontWeight: 700,
fontSize: 20,
},
dim: { color: '#7d8590', fontWeight: 400 },
hint: { margin: 0, color: '#7d8590' },
input: {
background: '#0d1117',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '8px 12px',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
},
button: {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '8px 12px',
cursor: 'pointer',
fontWeight: 600,
},
linkButton: {
background: 'transparent',
color: '#58a6ff',
border: 'none',
cursor: 'pointer',
textAlign: 'left',
padding: 0,
},
help: {
background: '#0d1117',
color: '#c9d1d9',
padding: 12,
borderRadius: 4,
fontSize: 12,
overflowX: 'auto',
margin: 0,
},
};

View File

@@ -0,0 +1,155 @@
import * as React from 'react';
import { useState } from 'react';
import Editor from '@monaco-editor/react';
/**
* Inline prompt editor: name + priority + Monaco for content.
* Used by ProjectPrompts, AgentDetail (direct prompts tab), and
* PersonalityDetail (binding-attached prompts).
*
* The component is intentionally dumb — it owns no I/O. The parent does the
* POST/PUT and decides whether to show this in "create" or "edit" mode.
*/
export interface PromptDraft {
name: string;
priority: number;
content: string;
}
export interface PromptEditorProps {
initial?: Partial<PromptDraft>;
/** Lock the name field — used when editing existing prompts (name is the unique key). */
nameLocked?: boolean;
submitLabel: string;
onSubmit: (draft: PromptDraft) => Promise<void> | void;
onCancel?: () => void;
busy?: boolean;
}
export function PromptEditor(props: PromptEditorProps): React.JSX.Element {
const [name, setName] = useState(props.initial?.name ?? '');
const [priority, setPriority] = useState(props.initial?.priority ?? 5);
const [content, setContent] = useState(props.initial?.content ?? '');
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: React.FormEvent): Promise<void> {
e.preventDefault();
setError(null);
if (!/^[a-z0-9-]+$/.test(name)) {
setError('Name must be lowercase alphanumeric with hyphens (e.g., "tone-rules").');
return;
}
if (content.trim().length === 0) {
setError('Content is required.');
return;
}
try {
await props.onSubmit({ name, priority, content });
} catch (err) {
setError((err as Error).message);
}
}
return (
<form onSubmit={handleSubmit} style={styles.form}>
<div style={styles.row}>
<label style={styles.label}>
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
disabled={props.nameLocked === true}
placeholder="lowercase-with-hyphens"
style={styles.input}
/>
</label>
<label style={styles.label}>
Priority (1-10)
<input
type="number"
min={1}
max={10}
value={priority}
onChange={(e) => setPriority(Number(e.target.value))}
style={{ ...styles.input, width: 80 }}
/>
</label>
</div>
<div style={styles.editorShell}>
<Editor
height="320px"
theme="vs-dark"
defaultLanguage="markdown"
value={content}
onChange={(v) => setContent(v ?? '')}
options={{
minimap: { enabled: false },
wordWrap: 'on',
fontSize: 13,
automaticLayout: true,
}}
/>
</div>
{error !== null && <div style={styles.error}>{error}</div>}
<div style={styles.actions}>
<button type="submit" disabled={props.busy === true} style={styles.primary}>
{props.busy === true ? 'Saving…' : props.submitLabel}
</button>
{props.onCancel !== undefined && (
<button type="button" onClick={props.onCancel} style={styles.secondary}>
Cancel
</button>
)}
</div>
</form>
);
}
const styles: Record<string, React.CSSProperties> = {
form: { display: 'flex', flexDirection: 'column', gap: 12 },
row: { display: 'flex', gap: 16, alignItems: 'flex-end' },
label: {
display: 'flex',
flexDirection: 'column',
gap: 4,
fontSize: 12,
color: '#7d8590',
flex: 1,
},
input: {
background: '#0d1117',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '6px 10px',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
},
editorShell: { border: '1px solid #30363d', borderRadius: 4, overflow: 'hidden' },
error: {
background: '#2d1416',
color: '#ff7b72',
border: '1px solid #f85149',
padding: '8px 12px',
borderRadius: 4,
fontSize: 13,
},
actions: { display: 'flex', gap: 8 },
primary: {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 600,
},
secondary: {
background: 'transparent',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '8px 16px',
cursor: 'pointer',
},
};

View File

@@ -0,0 +1,35 @@
import { useEffect, useState, useCallback } from 'react';
/**
* Minimal SWR-style hook: call `fn`, expose `{ data, error, loading, refetch }`.
* No global cache — each consumer fetches its own copy. Add caching when you
* see the same query firing 5+ times across mounted components.
*/
export interface UseFetchResult<T> {
data: T | null;
error: Error | null;
loading: boolean;
refetch: () => void;
}
export function useFetch<T>(fn: () => Promise<T>, deps: unknown[] = []): UseFetchResult<T> {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
const [bump, setBump] = useState(0);
const refetch = useCallback(() => setBump((b) => b + 1), []);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fn()
.then((d) => { if (!cancelled) { setData(d); setLoading(false); } })
.catch((e: Error) => { if (!cancelled) { setError(e); setLoading(false); } });
return () => { cancelled = true; };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bump, ...deps]);
return { data, error, loading, refetch };
}

11
src/web/src/main.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
const root = document.getElementById('root');
if (root === null) throw new Error('#root not found');
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,317 @@
import * as React from 'react';
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, type Agent, type Prompt, type Personality } from '../api';
import { useFetch } from '../hooks/useFetch';
import { PromptEditor, type PromptDraft } from '../components/PromptEditor';
/**
* Agent detail page. Two tabs:
* - Direct prompts (Prompt.agentId === agent.id) — always-on overlay.
* - Personalities — list, create, click-through to bind prompts.
*
* Why both on one page: an agent's "what it does" lives in this triple
* (systemPrompt + direct prompts + personalities). Splitting them across
* routes hides the relationship that the chat engine cares about.
*/
type Tab = 'direct' | 'personalities';
export function AgentDetailPage(): React.JSX.Element {
const { name } = useParams<{ name: string }>();
const agentName = name ?? '';
const { data: agent, error: agentError, loading: agentLoading } = useFetch(
() => api.get<Agent>(`/api/v1/agents/${encodeURIComponent(agentName)}`),
[agentName],
);
const [tab, setTab] = useState<Tab>('direct');
if (agentLoading) return <div>Loading agent</div>;
if (agentError !== null) return <div style={{ color: '#ff7b72' }}>Error: {agentError.message}</div>;
if (agent === null) return <div>Not found.</div>;
return (
<section>
<p style={{ marginTop: 0 }}>
<Link to="/agents" style={{ color: '#58a6ff' }}> Agents</Link>
</p>
<h2 style={{ marginTop: 0 }}>
{agent.name}
<span style={{ color: '#7d8590', fontSize: 14, marginLeft: 12 }}>
{agent.llm.name}{agent.project !== null && ` · ${agent.project.name}`}
</span>
</h2>
{agent.systemPrompt !== '' && (
<details style={{ background: '#161b22', border: '1px solid #30363d', borderRadius: 6, padding: 12, margin: '12px 0' }}>
<summary style={{ cursor: 'pointer', color: '#7d8590' }}>System prompt</summary>
<pre style={{ background: '#0d1117', padding: 12, borderRadius: 4, marginTop: 8, whiteSpace: 'pre-wrap' }}>{agent.systemPrompt}</pre>
</details>
)}
<div style={{ display: 'flex', gap: 4, borderBottom: '1px solid #30363d', marginBottom: 16 }}>
<TabButton active={tab === 'direct'} onClick={() => setTab('direct')}>Direct prompts</TabButton>
<TabButton active={tab === 'personalities'} onClick={() => setTab('personalities')}>Personalities</TabButton>
</div>
{tab === 'direct' && <DirectPromptsTab agentName={agentName} />}
{tab === 'personalities' && <PersonalitiesTab agentName={agentName} />}
</section>
);
}
function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.JSX.Element {
return (
<button
onClick={onClick}
style={{
background: 'transparent',
color: active ? '#58a6ff' : '#c9d1d9',
border: 'none',
borderBottom: active ? '2px solid #58a6ff' : '2px solid transparent',
padding: '8px 16px',
cursor: 'pointer',
marginBottom: -1,
}}
>
{children}
</button>
);
}
// ── Direct prompts tab ──
function DirectPromptsTab({ agentName }: { agentName: string }): React.JSX.Element {
const { data, error, loading, refetch } = useFetch(
() => api.get<Prompt[]>(`/api/v1/agents/${encodeURIComponent(agentName)}/prompts`),
[agentName],
);
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState(false);
if (loading) return <div>Loading</div>;
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
const prompts = data ?? [];
async function handleCreate(draft: PromptDraft): Promise<void> {
setBusy(true);
try {
await api.post('/api/v1/prompts', {
name: draft.name,
content: draft.content,
priority: draft.priority,
agent: agentName,
});
setCreating(false);
refetch();
} finally {
setBusy(false);
}
}
async function handleDelete(id: string, name: string): Promise<void> {
if (!confirm(`Delete prompt '${name}'?`)) return;
await api.delete(`/api/v1/prompts/${id}`);
refetch();
}
return (
<div>
<p style={{ color: '#7d8590', marginTop: 0 }}>
Always-on prompts for this agent. Injected after the agent's system prompt and before project prompts.
</p>
{!creating && (
<button onClick={() => setCreating(true)} style={primaryBtn}>+ New direct prompt</button>
)}
{creating && (
<div style={card}>
<h3>New direct prompt</h3>
<PromptEditor
submitLabel="Create"
onSubmit={handleCreate}
onCancel={() => setCreating(false)}
busy={busy}
/>
</div>
)}
{prompts.length === 0 && !creating && <p style={{ color: '#7d8590' }}>No direct prompts yet.</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: '16px 0 0 0' }}>
{prompts.sort((a, b) => b.priority - a.priority).map((p) => (
<li key={p.id} style={card}>
<div style={cardHeader}>
<div>
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{p.name}</strong>
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>priority {p.priority}</span>
</div>
<button onClick={() => handleDelete(p.id, p.name)} style={dangerBtn}>Delete</button>
</div>
<pre style={contentPre}>{p.content}</pre>
</li>
))}
</ul>
</div>
);
}
// ── Personalities tab ──
function PersonalitiesTab({ agentName }: { agentName: string }): React.JSX.Element {
const { data, error, loading, refetch } = useFetch(
() => api.get<Personality[]>(`/api/v1/agents/${encodeURIComponent(agentName)}/personalities`),
[agentName],
);
const [creating, setCreating] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [busy, setBusy] = useState(false);
if (loading) return <div>Loading…</div>;
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
const personalities = data ?? [];
async function handleCreate(e: React.FormEvent): Promise<void> {
e.preventDefault();
if (!/^[a-z0-9-]+$/.test(name)) {
alert('Name must be lowercase alphanumeric with hyphens.');
return;
}
setBusy(true);
try {
await api.post(`/api/v1/agents/${encodeURIComponent(agentName)}/personalities`, {
name, description,
});
setName('');
setDescription('');
setCreating(false);
refetch();
} catch (err) {
alert((err as Error).message);
} finally {
setBusy(false);
}
}
async function handleDelete(id: string, name: string): Promise<void> {
if (!confirm(`Delete personality '${name}'?`)) return;
await api.delete(`/api/v1/personalities/${id}`);
refetch();
}
return (
<div>
<p style={{ color: '#7d8590', marginTop: 0 }}>
Named overlays of prompts. Pick at chat time with <code>--personality &lt;name&gt;</code> or set
a default on the agent.
</p>
{!creating && (
<button onClick={() => setCreating(true)} style={primaryBtn}>+ New personality</button>
)}
{creating && (
<form onSubmit={handleCreate} style={card}>
<h3>New personality</h3>
<div style={{ display: 'flex', gap: 12, marginBottom: 12 }}>
<label style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#7d8590' }}>
Name
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="grumpy"
style={input}
/>
</label>
<label style={{ flex: 2, display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12, color: '#7d8590' }}>
Description
<input
type="text"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Be terse and slightly grumpy"
style={input}
/>
</label>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button type="submit" disabled={busy} style={primaryBtn}>{busy ? 'Saving' : 'Create'}</button>
<button type="button" onClick={() => setCreating(false)} style={secondaryBtn}>Cancel</button>
</div>
</form>
)}
{personalities.length === 0 && !creating && <p style={{ color: '#7d8590' }}>No personalities yet.</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: '16px 0 0 0' }}>
{personalities.map((p) => (
<li key={p.id} style={card}>
<div style={cardHeader}>
<div>
<Link
to={`/personalities/${p.id}`}
style={{ color: '#58a6ff', fontFamily: 'ui-monospace, monospace', fontWeight: 600, textDecoration: 'none' }}
>
{p.name}
</Link>
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>
{p.promptCount} prompt{p.promptCount === 1 ? '' : 's'}
</span>
</div>
<button onClick={() => handleDelete(p.id, p.name)} style={dangerBtn}>Delete</button>
</div>
{p.description !== '' && <div style={{ color: '#c9d1d9' }}>{p.description}</div>}
</li>
))}
</ul>
</div>
);
}
const card: React.CSSProperties = {
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 6,
padding: 16,
margin: '12px 0',
};
const cardHeader: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
};
const contentPre: React.CSSProperties = {
background: '#0d1117',
color: '#c9d1d9',
padding: 12,
borderRadius: 4,
margin: 0,
whiteSpace: 'pre-wrap',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 12,
};
const input: React.CSSProperties = {
background: '#0d1117',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '6px 10px',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
};
const primaryBtn: React.CSSProperties = {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 600,
};
const secondaryBtn: React.CSSProperties = {
background: 'transparent',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '8px 16px',
cursor: 'pointer',
};
const dangerBtn: React.CSSProperties = {
background: 'transparent',
color: '#ff7b72',
border: '1px solid #f85149',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
};

View File

@@ -0,0 +1,40 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { api, type Agent } from '../api';
import { useFetch } from '../hooks/useFetch';
export function AgentsPage(): React.JSX.Element {
const { data, error, loading } = useFetch(() => api.get<Agent[]>('/api/v1/agents'));
if (loading) return <div>Loading agents</div>;
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
const agents = data ?? [];
return (
<section>
<h2 style={{ marginTop: 0 }}>Agents</h2>
<p style={{ color: '#7d8590' }}>Pick an agent to manage its direct prompts and personalities.</p>
{agents.length === 0 && <p>No agents yet create one with <code>mcpctl create agent</code>.</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{agents.map((a) => (
<li key={a.id} style={{ padding: '12px 0', borderBottom: '1px solid #30363d' }}>
<Link
to={`/agents/${encodeURIComponent(a.name)}`}
style={{ color: '#58a6ff', textDecoration: 'none', fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace', fontSize: 15 }}
>
{a.name}
</Link>
<div style={{ color: '#7d8590', fontSize: 12, marginTop: 4 }}>
LLM: {a.llm.name}
{a.project !== null && <> · Project: {a.project.name}</>}
{a.defaultPersonality !== null && <> · Default personality: {a.defaultPersonality.name}</>}
</div>
{a.description !== '' && (
<div style={{ color: '#c9d1d9', marginTop: 4 }}>{a.description}</div>
)}
</li>
))}
</ul>
</section>
);
}

View File

@@ -0,0 +1,224 @@
import * as React from 'react';
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, type Personality, type PersonalityPrompt, type Prompt } from '../api';
import { useFetch } from '../hooks/useFetch';
/**
* Personality detail: show metadata, list bound prompts (with priority
* within this overlay), and offer an "attach prompt" picker that lets the
* user select from in-scope candidates (agent-direct + same-project + global).
*/
export function PersonalityDetailPage(): React.JSX.Element {
const { id } = useParams<{ id: string }>();
const personalityId = id ?? '';
const personality = useFetch(
() => api.get<Personality>(`/api/v1/personalities/${encodeURIComponent(personalityId)}`),
[personalityId],
);
const bindings = useFetch(
() => api.get<PersonalityPrompt[]>(`/api/v1/personalities/${encodeURIComponent(personalityId)}/prompts`),
[personalityId],
);
if (personality.loading || bindings.loading) return <div>Loading</div>;
if (personality.error !== null) return <div style={{ color: '#ff7b72' }}>Error: {personality.error.message}</div>;
if (personality.data === null) return <div>Not found.</div>;
const p = personality.data;
const bound = bindings.data ?? [];
return (
<section>
<p style={{ marginTop: 0 }}>
<Link to={`/agents/${encodeURIComponent(p.agentName)}`} style={{ color: '#58a6ff' }}>
{p.agentName}
</Link>
</p>
<h2 style={{ marginTop: 0 }}>
{p.name}
<span style={{ color: '#7d8590', fontSize: 14, marginLeft: 12 }}>personality on {p.agentName}</span>
</h2>
{p.description !== '' && <p style={{ color: '#c9d1d9' }}>{p.description}</p>}
<h3>Bound prompts ({bound.length})</h3>
<p style={{ color: '#7d8590', marginTop: 0 }}>
Activated when this personality is selected at chat time. Priority controls order within
the overlay (higher first).
</p>
{bound.length === 0 && <p style={{ color: '#7d8590' }}>No bound prompts yet.</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{bound
.slice()
.sort((a, b) => b.priority - a.priority)
.map((b) => (
<li key={b.promptId} style={card}>
<div style={cardHeader}>
<div>
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{b.promptName}</strong>
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>priority {b.priority}</span>
</div>
<button onClick={() => detach(personalityId, b.promptId, b.promptName, bindings.refetch)} style={dangerBtn}>
Detach
</button>
</div>
<pre style={contentPre}>{b.promptContent}</pre>
</li>
))}
</ul>
<AttachPromptPanel
personality={p}
boundPromptIds={bound.map((b) => b.promptId)}
onAttached={bindings.refetch}
/>
</section>
);
}
async function detach(
personalityId: string,
promptId: string,
promptName: string,
refetch: () => void,
): Promise<void> {
if (!confirm(`Detach prompt '${promptName}' from this personality?`)) return;
await api.delete(`/api/v1/personalities/${personalityId}/prompts/${promptId}`);
refetch();
}
// ── Attach picker ──
function AttachPromptPanel(props: {
personality: Personality;
boundPromptIds: string[];
onAttached: () => void;
}): React.JSX.Element {
const [open, setOpen] = useState(false);
const candidates = useFetch(
async () => {
// In-scope candidates: agent-direct + same-project + global.
const direct = await api.get<Prompt[]>(
`/api/v1/agents/${encodeURIComponent(props.personality.agentName)}/prompts`,
);
const agentRow = await api.get<{ project: { name: string } | null }>(
`/api/v1/agents/${encodeURIComponent(props.personality.agentName)}`,
);
let projectAndGlobal: Prompt[] = [];
if (agentRow.project !== null) {
projectAndGlobal = await api.get<Prompt[]>(
`/api/v1/prompts?project=${encodeURIComponent(agentRow.project.name)}`,
);
} else {
projectAndGlobal = await api.get<Prompt[]>(`/api/v1/prompts?scope=global`);
}
// Dedupe; exclude ones already bound.
const seen = new Set<string>(props.boundPromptIds);
const merged: Prompt[] = [];
for (const p of [...direct, ...projectAndGlobal]) {
if (seen.has(p.id)) continue;
seen.add(p.id);
merged.push(p);
}
return merged;
},
[props.personality.id, props.boundPromptIds.join(',')],
);
const [busyId, setBusyId] = useState<string | null>(null);
async function attach(promptId: string): Promise<void> {
setBusyId(promptId);
try {
await api.post(`/api/v1/personalities/${props.personality.id}/prompts`, { promptId });
props.onAttached();
} catch (err) {
alert((err as Error).message);
} finally {
setBusyId(null);
}
}
if (!open) {
return <button onClick={() => setOpen(true)} style={primaryBtn}>+ Attach prompt</button>;
}
return (
<div style={card}>
<h3 style={{ marginTop: 0 }}>Attach a prompt</h3>
<p style={{ color: '#7d8590', marginTop: 0 }}>
Eligible: prompts on this agent, prompts in the agent's project, or globals.
</p>
{candidates.loading && <div>Loading candidates…</div>}
{candidates.error !== null && <div style={{ color: '#ff7b72' }}>Error: {candidates.error.message}</div>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{(candidates.data ?? []).map((p) => (
<li key={p.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '8px 0', borderBottom: '1px solid #30363d' }}>
<div>
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{p.name}</strong>
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>
{p.agentId !== null ? 'agent-direct' : p.projectId !== null ? 'project' : 'global'}
</span>
</div>
<button onClick={() => attach(p.id)} disabled={busyId === p.id} style={secondaryBtn}>
{busyId === p.id ? 'Attaching' : 'Attach'}
</button>
</li>
))}
{(candidates.data ?? []).length === 0 && !candidates.loading && (
<li style={{ color: '#7d8590', padding: '8px 0' }}>No eligible prompts to attach.</li>
)}
</ul>
<button onClick={() => setOpen(false)} style={secondaryBtn}>Close</button>
</div>
);
}
const card: React.CSSProperties = {
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 6,
padding: 16,
margin: '12px 0',
};
const cardHeader: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
};
const contentPre: React.CSSProperties = {
background: '#0d1117',
color: '#c9d1d9',
padding: 12,
borderRadius: 4,
margin: 0,
whiteSpace: 'pre-wrap',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 12,
};
const primaryBtn: React.CSSProperties = {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 600,
};
const secondaryBtn: React.CSSProperties = {
background: 'transparent',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
};
const dangerBtn: React.CSSProperties = {
background: 'transparent',
color: '#ff7b72',
border: '1px solid #f85149',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
};

View File

@@ -0,0 +1,177 @@
import * as React from 'react';
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api, type Prompt } from '../api';
import { useFetch } from '../hooks/useFetch';
import { PromptEditor, type PromptDraft } from '../components/PromptEditor';
/**
* Project prompts editor:
* - GET /api/v1/prompts?project=<name> to list (project-scoped + globals)
* - filter to project-only for this view
* - inline create / edit / delete; Monaco for content
*/
export function ProjectPromptsPage(): React.JSX.Element {
const { name } = useParams<{ name: string }>();
const projectName = name ?? '';
const { data, error, loading, refetch } = useFetch(
() => api.get<Prompt[]>(`/api/v1/prompts?project=${encodeURIComponent(projectName)}`),
[projectName],
);
const [editingId, setEditingId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [busy, setBusy] = useState(false);
if (loading) return <div>Loading prompts</div>;
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
const all = data ?? [];
// Filter to project-scoped only — the API includes globals as well.
const prompts = all.filter((p) => p.projectId !== null);
async function handleCreate(draft: PromptDraft): Promise<void> {
setBusy(true);
try {
await api.post('/api/v1/prompts', {
name: draft.name,
content: draft.content,
priority: draft.priority,
project: projectName,
});
setCreating(false);
refetch();
} finally {
setBusy(false);
}
}
async function handleUpdate(id: string, draft: PromptDraft): Promise<void> {
setBusy(true);
try {
await api.put(`/api/v1/prompts/${id}`, {
content: draft.content,
priority: draft.priority,
});
setEditingId(null);
refetch();
} finally {
setBusy(false);
}
}
async function handleDelete(id: string, name: string): Promise<void> {
if (!confirm(`Delete prompt '${name}'?`)) return;
await api.delete(`/api/v1/prompts/${id}`);
refetch();
}
return (
<section>
<p style={{ marginTop: 0 }}>
<Link to="/projects" style={{ color: '#58a6ff' }}> Projects</Link>
</p>
<h2 style={{ marginTop: 0 }}>{projectName}<span style={{ color: '#7d8590' }}> · prompts</span></h2>
{!creating && (
<button onClick={() => setCreating(true)} style={primaryBtn}>+ New prompt</button>
)}
{creating && (
<div style={card}>
<h3>New prompt</h3>
<PromptEditor
submitLabel="Create"
onSubmit={handleCreate}
onCancel={() => setCreating(false)}
busy={busy}
/>
</div>
)}
{prompts.length === 0 && !creating && (
<p style={{ color: '#7d8590' }}>No prompts in this project yet.</p>
)}
<ul style={{ listStyle: 'none', padding: 0, margin: '16px 0 0 0' }}>
{prompts.sort((a, b) => b.priority - a.priority).map((p) => (
<li key={p.id} style={card}>
{editingId === p.id ? (
<>
<h3>Edit prompt: {p.name}</h3>
<PromptEditor
initial={{ name: p.name, priority: p.priority, content: p.content }}
nameLocked
submitLabel="Save"
onSubmit={(d) => handleUpdate(p.id, d)}
onCancel={() => setEditingId(null)}
busy={busy}
/>
</>
) : (
<>
<div style={cardHeader}>
<div>
<strong style={{ fontFamily: 'ui-monospace, monospace' }}>{p.name}</strong>
<span style={{ color: '#7d8590', marginLeft: 8, fontSize: 12 }}>
priority {p.priority}
</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={() => setEditingId(p.id)} style={secondaryBtn}>Edit</button>
<button onClick={() => handleDelete(p.id, p.name)} style={dangerBtn}>Delete</button>
</div>
</div>
<pre style={contentPre}>{p.content}</pre>
</>
)}
</li>
))}
</ul>
</section>
);
}
const card: React.CSSProperties = {
background: '#161b22',
border: '1px solid #30363d',
borderRadius: 6,
padding: 16,
margin: '12px 0',
};
const cardHeader: React.CSSProperties = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
};
const contentPre: React.CSSProperties = {
background: '#0d1117',
color: '#c9d1d9',
padding: 12,
borderRadius: 4,
margin: 0,
whiteSpace: 'pre-wrap',
fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace',
fontSize: 12,
};
const primaryBtn: React.CSSProperties = {
background: '#238636',
color: '#fff',
border: 'none',
borderRadius: 4,
padding: '8px 16px',
cursor: 'pointer',
fontWeight: 600,
};
const secondaryBtn: React.CSSProperties = {
background: 'transparent',
color: '#c9d1d9',
border: '1px solid #30363d',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
};
const dangerBtn: React.CSSProperties = {
background: 'transparent',
color: '#ff7b72',
border: '1px solid #f85149',
borderRadius: 4,
padding: '4px 12px',
cursor: 'pointer',
};

View File

@@ -0,0 +1,35 @@
import * as React from 'react';
import { Link } from 'react-router-dom';
import { api, type Project } from '../api';
import { useFetch } from '../hooks/useFetch';
export function ProjectsPage(): React.JSX.Element {
const { data, error, loading } = useFetch(() => api.get<Project[]>('/api/v1/projects'));
if (loading) return <div>Loading projects</div>;
if (error !== null) return <div style={{ color: '#ff7b72' }}>Error: {error.message}</div>;
const projects = data ?? [];
return (
<section>
<h2 style={{ marginTop: 0 }}>Projects</h2>
<p style={{ color: '#7d8590' }}>Pick a project to edit its prompts.</p>
{projects.length === 0 && <p>No projects yet.</p>}
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
{projects.map((p) => (
<li key={p.id} style={{ padding: '8px 0', borderBottom: '1px solid #30363d' }}>
<Link
to={`/projects/${encodeURIComponent(p.name)}/prompts`}
style={{ color: '#58a6ff', textDecoration: 'none', fontFamily: 'ui-monospace, "SF Mono", Menlo, monospace' }}
>
{p.name}
</Link>
{p.description !== undefined && p.description !== '' && (
<span style={{ color: '#7d8590', marginLeft: 12 }}>{p.description}</span>
)}
</li>
))}
</ul>
</section>
);
}

62
src/web/tests/api.test.ts Normal file
View File

@@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { api, setToken, clearToken, type ApiError } from '../src/api';
describe('api wrapper', () => {
beforeEach(() => {
clearToken();
vi.restoreAllMocks();
});
it('attaches the bearer token from localStorage', async () => {
setToken('mcpctl_pat_xyz');
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ ok: true }), {
status: 200, headers: { 'content-type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await api.get('/api/v1/agents');
const calls = fetchMock.mock.calls as unknown as Array<[unknown, RequestInit]>;
expect(calls.length).toBeGreaterThan(0);
const init = calls[0]![1];
expect(init.headers).toMatchObject({
'Authorization': 'Bearer mcpctl_pat_xyz',
'Content-Type': 'application/json',
});
});
it('omits the bearer header when no token is set', async () => {
const fetchMock = vi.fn(async () => new Response('[]', {
status: 200, headers: { 'content-type': 'application/json' },
}));
vi.stubGlobal('fetch', fetchMock);
await api.get('/api/v1/agents');
const calls = fetchMock.mock.calls as unknown as Array<[unknown, RequestInit]>;
expect(calls.length).toBeGreaterThan(0);
const init = calls[0]![1];
expect(init.headers).not.toHaveProperty('Authorization');
});
it('throws an ApiError with status + parsed body on 4xx/5xx', async () => {
const fetchMock = vi.fn(async () => new Response(
JSON.stringify({ error: 'nope' }),
{ status: 422, statusText: 'Unprocessable', headers: { 'content-type': 'application/json' } },
));
vi.stubGlobal('fetch', fetchMock);
await expect(api.get('/api/v1/oops')).rejects.toMatchObject({
status: 422,
body: { error: 'nope' },
} satisfies Partial<ApiError>);
});
it('handles 204 No Content responses without parsing JSON', async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
vi.stubGlobal('fetch', fetchMock);
const result = await api.delete('/api/v1/prompts/abc');
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { Login } from '../src/components/Login';
import { getToken, clearToken } from '../src/api';
describe('Login', () => {
beforeEach(() => {
clearToken();
});
it('stores the pasted token and calls onLogin', () => {
let logged = false;
render(<Login onLogin={() => { logged = true; }} />);
const input = screen.getByPlaceholderText(/mcpctl_pat_/i) as HTMLInputElement;
fireEvent.change(input, { target: { value: 'mcpctl_pat_test_abc' } });
fireEvent.click(screen.getByText(/Continue/));
expect(getToken()).toBe('mcpctl_pat_test_abc');
expect(logged).toBe(true);
});
it('does nothing on empty submit', () => {
let logged = false;
render(<Login onLogin={() => { logged = true; }} />);
fireEvent.click(screen.getByText(/Continue/));
expect(getToken()).toBeNull();
expect(logged).toBe(false);
});
it('toggles the help panel', () => {
render(<Login onLogin={() => {}} />);
expect(screen.queryByText(/mcpctl auth login/)).toBeNull();
fireEvent.click(screen.getByText(/Where do I get a token/));
expect(screen.getByText(/mcpctl auth login/)).toBeInTheDocument();
});
});

1
src/web/tests/setup.ts Normal file
View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom';

25
src/web/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"resolveJsonModule": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"allowImportingTsExtensions": false,
"verbatimModuleSyntax": false,
"noEmit": true
},
"include": ["src/**/*", "tests/**/*"]
}

41
src/web/vite.config.ts Normal file
View File

@@ -0,0 +1,41 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
/**
* Vite config for the @mcpctl/web prompt editor.
*
* - `base: '/ui/'` so production builds work when served by mcpd at
* `https://mcpctl.ad.itaz.eu/ui/` via @fastify/static.
* - Dev server proxies `/api` to mcpd so the same fetch wrapper works in
* both modes (in prod the UI is same-origin with mcpd, so no proxy needed).
* Override the dev target via `MCPCTL_API_URL` for non-default deployments.
* - The build artifact lands in `dist/` and is consumed by
* `scripts/build-rpm.sh` in Stage 6.
*/
const apiTarget = process.env['MCPCTL_API_URL'] ?? 'https://mcpctl.ad.itaz.eu';
export default defineConfig({
plugins: [react()],
base: '/ui/',
server: {
port: 5173,
proxy: {
'/api': {
target: apiTarget,
changeOrigin: true,
secure: false,
},
},
},
build: {
outDir: 'dist',
sourcemap: true,
chunkSizeWarningLimit: 2500,
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
});