From f60f00f1fd08d29f1374679f235bd27ca7761ac2 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:12:22 +0100 Subject: [PATCH 1/6] feat(db): add personalities + agent-direct prompts schema (Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Personality is a named overlay on top of an Agent — same agent, same LLM, but a different bundle of prompts injected into the system block at chat time. VLAN-on-ethernet semantics: ethernet still works without VLAN; with a VLAN tag, frames are segmented but still ethernet. Schema additions: - Prompt.agentId (nullable FK + index, cascade on delete) so prompts can attach directly to an agent without going through a project. - Personality { id, name, description, agentId, priority } with unique (name, agentId). - PersonalityPrompt join table with per-binding priority override. - Agent.defaultPersonalityId (SetNull on delete) so an agent can pick one personality as the default when no --personality flag is passed. Backwards-compatible by construction: every new column is nullable; existing rows are valid as-is; the chat.service systemBlock changes land in Stage 3. 8 new prisma-level assertions in agent-schema.test.ts cover unique constraints, cascade behavior, the SetNull on defaultPersonalityId, and shared-prompt-across-personalities. All 16 db tests pass; mcpd typecheck + 777 mcpd unit tests still green. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 59 +++++ src/db/prisma/schema.prisma | 228 +++++++++++------- src/db/tests/agent-schema.test.ts | 142 +++++++++++ src/db/tests/helpers.ts | 4 + 4 files changed, 347 insertions(+), 86 deletions(-) create mode 100644 src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql diff --git a/src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql b/src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql new file mode 100644 index 0000000..bdb244b --- /dev/null +++ b/src/db/prisma/migrations/20260426180927_add_personalities_and_agent_prompts/migration.sql @@ -0,0 +1,59 @@ +-- Add agent-direct prompts and per-agent personalities (with personality<->prompt join). + +-- ── Prompt: optional agentId, mirrors projectId pattern ── +ALTER TABLE "Prompt" ADD COLUMN "agentId" TEXT; + +CREATE UNIQUE INDEX "Prompt_name_agentId_key" ON "Prompt"("name", "agentId"); +CREATE INDEX "Prompt_agentId_idx" ON "Prompt"("agentId"); + +ALTER TABLE "Prompt" ADD CONSTRAINT "Prompt_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- ── Agent: defaultPersonalityId (optional FK; SET NULL on delete to keep agent alive) ── +ALTER TABLE "Agent" ADD COLUMN "defaultPersonalityId" TEXT; + +CREATE INDEX "Agent_defaultPersonalityId_idx" ON "Agent"("defaultPersonalityId"); + +-- ── Personality table ── +CREATE TABLE "Personality" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "agentId" TEXT NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 5, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Personality_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "Personality_name_agentId_key" ON "Personality"("name", "agentId"); +CREATE INDEX "Personality_agentId_idx" ON "Personality"("agentId"); + +ALTER TABLE "Personality" ADD CONSTRAINT "Personality_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- Now that Personality exists, the Agent.defaultPersonalityId FK can point at it. +ALTER TABLE "Agent" ADD CONSTRAINT "Agent_defaultPersonalityId_fkey" + FOREIGN KEY ("defaultPersonalityId") REFERENCES "Personality"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- ── PersonalityPrompt join table ── +CREATE TABLE "PersonalityPrompt" ( + "id" TEXT NOT NULL, + "personalityId" TEXT NOT NULL, + "promptId" TEXT NOT NULL, + "priority" INTEGER NOT NULL DEFAULT 5, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PersonalityPrompt_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "PersonalityPrompt_personalityId_promptId_key" ON "PersonalityPrompt"("personalityId", "promptId"); +CREATE INDEX "PersonalityPrompt_personalityId_idx" ON "PersonalityPrompt"("personalityId"); +CREATE INDEX "PersonalityPrompt_promptId_idx" ON "PersonalityPrompt"("promptId"); + +ALTER TABLE "PersonalityPrompt" ADD CONSTRAINT "PersonalityPrompt_personalityId_fkey" + FOREIGN KEY ("personalityId") REFERENCES "Personality"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "PersonalityPrompt" ADD CONSTRAINT "PersonalityPrompt_promptId_fkey" + FOREIGN KEY ("promptId") REFERENCES "Prompt"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4089f87..a52f1ae 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -21,13 +21,13 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - sessions Session[] - auditLogs AuditLog[] - ownedProjects Project[] - groupMemberships GroupMember[] - mcpTokens McpToken[] - ownedAgents Agent[] - chatThreads ChatThread[] + sessions Session[] + auditLogs AuditLog[] + ownedProjects Project[] + groupMemberships GroupMember[] + mcpTokens McpToken[] + ownedAgents Agent[] + chatThreads ChatThread[] @@index([email]) } @@ -56,23 +56,23 @@ model Session { // ── MCP Servers ── model McpServer { - id String @id @default(cuid()) - name String @unique - description String @default("") + id String @id @default(cuid()) + name String @unique + description String @default("") packageName String? runtime String? dockerImage String? - transport Transport @default(STDIO) + transport Transport @default(STDIO) repositoryUrl String? externalUrl String? command Json? containerPort Int? - replicas Int @default(1) - env Json @default("[]") + replicas Int @default(1) + env Json @default("[]") healthCheck Json? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt templateName String? templateVersion String? @@ -125,15 +125,15 @@ model McpTemplate { model SecretBackend { id String @id @default(cuid()) name String @unique - type String // plaintext | openbao | (future: vault, aws-sm, ...) - config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef + type String // plaintext | openbao | (future: vault, aws-sm, ...) + config Json @default("{}") // type-specific: url, mount, namespace, tokenSecretRef // Runtime metadata for auto-rotating backend credentials (openbao token // auth). Fields: generatedAt, nextRenewalAt, validUntil, lastRotationAt, // lastRotationError, rotatable (true only for wizard-provisioned tokens). // Empty object for backends that don't use rotation (plaintext, kubernetes // auth, or static tokens). Managed entirely by the rotator service. tokenMeta Json @default("{}") - isDefault Boolean @default(false) // exactly one row has isDefault=true + isDefault Boolean @default(false) // exactly one row has isDefault=true description String @default("") version Int @default(1) createdAt DateTime @default(now()) @@ -156,8 +156,8 @@ model Secret { // on next mcpd startup. New rows written by SecretService always carry a // valid FK immediately. backendId String @default("") - data Json @default("{}") // populated by plaintext backend only - externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") + data Json @default("{}") // populated by plaintext backend only + externalRef String @default("") // populated by non-plaintext backends (e.g. "mount/path#v3") // Sorted list of the secret's data keys WITHOUT their values. Populated on // every create/update/migrate so list views and describe-without-reveal can // show "this secret has GRAFANA_URL + GRAFANA_TOKEN" without fetching the @@ -186,14 +186,14 @@ model Secret { model Llm { id String @id @default(cuid()) name String @unique - type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli - model String // e.g. claude-3-5-sonnet-20241022 - url String @default("") // endpoint (empty for provider default) - tier String @default("fast") // fast | heavy + type String // anthropic | openai | deepseek | vllm | ollama | gemini-cli + model String // e.g. claude-3-5-sonnet-20241022 + url String @default("") // endpoint (empty for provider default) + tier String @default("fast") // fast | heavy description String @default("") - apiKeySecretId String? // FK to Secret - apiKeySecretKey String? // key inside the Secret's data - extraConfig Json @default("{}") // per-type extras + apiKeySecretId String? // FK to Secret + apiKeySecretKey String? // key inside the Secret's data + extraConfig Json @default("{}") // per-type extras version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -252,19 +252,19 @@ model RbacDefinition { // ── Projects ── model Project { - id String @id @default(cuid()) - name String @unique - description String @default("") - prompt String @default("") - proxyModel String @default("") - gated Boolean @default(true) + id String @id @default(cuid()) + name String @unique + description String @default("") + prompt String @default("") + proxyModel String @default("") + gated Boolean @default(true) llmProvider String? llmModel String? serverOverrides Json? ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] @@ -322,11 +322,11 @@ model McpToken { // ── MCP Instances (running containers) ── model McpInstance { - id String @id @default(cuid()) - serverId String - containerId String? - status InstanceStatus @default(STOPPED) - port Int? + id String @id @default(cuid()) + serverId String + containerId String? + status InstanceStatus @default(STOPPED) + port Int? metadata Json @default("{}") healthStatus String? lastHealthCheck DateTime? @@ -356,6 +356,7 @@ model Prompt { name String content String @db.Text projectId String? + agentId String? priority Int @default(5) summary String? @db.Text chapters Json? @@ -364,10 +365,14 @@ model Prompt { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) + personalities PersonalityPrompt[] @@unique([name, projectId]) + @@unique([name, agentId]) @@index([projectId]) + @@index([agentId]) } // ── Prompt Requests (pending proposals from LLM sessions) ── @@ -392,21 +397,21 @@ model PromptRequest { // ── Audit Events (pipeline/gate/tool trace from mcplocal) ── model AuditEvent { - id String @id @default(cuid()) - timestamp DateTime - sessionId String - projectName String - eventKind String - source String - verified Boolean @default(false) - serverName String? - correlationId String? - parentEventId String? - userName String? - tokenName String? - tokenSha String? - payload Json - createdAt DateTime @default(now()) + id String @id @default(cuid()) + timestamp DateTime + sessionId String + projectName String + eventKind String + source String + verified Boolean @default(false) + serverName String? + correlationId String? + parentEventId String? + userName String? + tokenName String? + tokenSha String? + payload Json + createdAt DateTime @default(now()) @@index([sessionId]) @@index([projectName]) @@ -423,7 +428,7 @@ model BackupPending { id String @id @default(cuid()) resourceKind String resourceName String - action String // 'create' | 'update' | 'delete' + action String // 'create' | 'update' | 'delete' userName String yamlContent String? @db.Text createdAt DateTime @default(now()) @@ -439,29 +444,80 @@ model BackupPending { // Per-call LiteLLM-style overrides stack on top of `defaultParams`. model Agent { - id String @id @default(cuid()) - name String @unique - description String @default("") // shown in MCP tools/list - systemPrompt String @default("") @db.Text // agent persona - llmId String - projectId String? - proxyModelName String? // optional informational override - defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... - extras Json @default("{}") // future LoRA / tool-allowlist - ownerId String - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String @unique + description String @default("") // shown in MCP tools/list + systemPrompt String @default("") @db.Text // agent persona + llmId String + projectId String? + defaultPersonalityId String? // applied at chat time when no --personality flag + proxyModelName String? // optional informational override + defaultParams Json @default("{}") // LiteLLM-style: temperature, top_p, top_k, max_tokens, stop, ... + extras Json @default("{}") // future LoRA / tool-allowlist + ownerId String + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt - llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) - project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) - threads ChatThread[] + llm Llm @relation(fields: [llmId], references: [id], onDelete: Restrict) + project Project? @relation(fields: [projectId], references: [id], onDelete: SetNull) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + threads ChatThread[] + prompts Prompt[] + personalities Personality[] @relation("AgentPersonalities") + defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) @@index([name]) @@index([llmId]) @@index([projectId]) @@index([ownerId]) + @@index([defaultPersonalityId]) +} + +// ── Personalities (named overlay bundles of prompts on top of an Agent) ── +// +// VLAN-on-ethernet semantics: an Agent works without a Personality (today's +// flow). Selecting a Personality adds its bound Prompts to the system block +// after the Agent's own systemPrompt, agent-direct prompts, and project +// prompts — additive, never replacing. + +model Personality { + id String @id @default(cuid()) + name String + description String @default("") + agentId String + priority Int @default(5) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + agent Agent @relation("AgentPersonalities", fields: [agentId], references: [id], onDelete: Cascade) + prompts PersonalityPrompt[] + defaultForAgent Agent[] @relation("AgentDefaultPersonality") + + @@unique([name, agentId]) + @@index([agentId]) +} + +// ── Personality ↔ Prompt join table ── +// +// A Prompt can be bound to many Personalities; a Personality can bind many +// Prompts. The `priority` here overrides the Prompt's own priority *within +// this Personality's overlay slice* (so the same prompt can be high-priority +// in one personality and low in another). + +model PersonalityPrompt { + id String @id @default(cuid()) + personalityId String + promptId String + priority Int @default(5) + createdAt DateTime @default(now()) + + personality Personality @relation(fields: [personalityId], references: [id], onDelete: Cascade) + prompt Prompt @relation(fields: [promptId], references: [id], onDelete: Cascade) + + @@unique([personalityId, promptId]) + @@index([personalityId]) + @@index([promptId]) } // ── Chat Threads (persisted conversation per Agent) ── @@ -493,11 +549,11 @@ model ChatMessage { id String @id @default(cuid()) threadId String turnIndex Int - role String // 'system' | 'user' | 'assistant' | 'tool' + role String // 'system' | 'user' | 'assistant' | 'tool' content String @db.Text - toolCalls Json? // assistant turn: [{id,name,arguments}] - toolCallId String? // tool turn: which call this answers - status String @default("complete") // 'pending' | 'complete' | 'error' + toolCalls Json? // assistant turn: [{id,name,arguments}] + toolCallId String? // tool turn: which call this answers + status String @default("complete") // 'pending' | 'complete' | 'error' createdAt DateTime @default(now()) thread ChatThread @relation(fields: [threadId], references: [id], onDelete: Cascade) @@ -509,13 +565,13 @@ model ChatMessage { // ── Audit Logs ── model AuditLog { - id String @id @default(cuid()) - userId String - action String - resource String + id String @id @default(cuid()) + userId String + action String + resource String resourceId String? - details Json @default("{}") - createdAt DateTime @default(now()) + details Json @default("{}") + createdAt DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/db/tests/agent-schema.test.ts b/src/db/tests/agent-schema.test.ts index 0761735..4fa3b0c 100644 --- a/src/db/tests/agent-schema.test.ts +++ b/src/db/tests/agent-schema.test.ts @@ -201,4 +201,146 @@ describe('agent / chat-thread / chat-message schema', () => { }); expect(ordered.map((t) => t.id)).toEqual([t2.id, t1.id]); }); + + // ── Agent-direct prompts (Prompt.agentId) ── + + it('attaches a prompt directly to an agent (no project)', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-agent-prompt'); + const agent = await makeAgent({ name: 'directprompt', llmId: llm.id, ownerId: user.id }); + + const prompt = await prisma.prompt.create({ + data: { name: 'agent-only', content: 'hi from agent', agentId: agent.id }, + }); + expect(prompt.agentId).toBe(agent.id); + expect(prompt.projectId).toBeNull(); + }); + + it('cascade-deletes agent-direct prompts when the agent is removed', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-agent-prompt-cascade'); + const agent = await makeAgent({ name: 'goingaway', llmId: llm.id, ownerId: user.id }); + await prisma.prompt.create({ + data: { name: 'p1', content: 'c', agentId: agent.id }, + }); + + await prisma.agent.delete({ where: { id: agent.id } }); + + expect(await prisma.prompt.count({ where: { agentId: agent.id } })).toBe(0); + }); + + it('enforces (name, agentId) uniqueness independently of (name, projectId)', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-uniq-prompt-agent'); + const agent = await makeAgent({ name: 'has-prompts', llmId: llm.id, ownerId: user.id }); + + await prisma.prompt.create({ + data: { name: 'shared', content: 'a', agentId: agent.id }, + }); + await expect( + prisma.prompt.create({ + data: { name: 'shared', content: 'b', agentId: agent.id }, + }), + ).rejects.toThrow(); + + // Same name on a different agent is fine. + const agent2 = await makeAgent({ name: 'other-agent', llmId: llm.id, ownerId: user.id }); + const ok = await prisma.prompt.create({ + data: { name: 'shared', content: 'c', agentId: agent2.id }, + }); + expect(ok.id).toBeDefined(); + }); + + // ── Personalities ── + + it('creates a personality bound to an agent', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-pers-1'); + const agent = await makeAgent({ name: 'with-pers', llmId: llm.id, ownerId: user.id }); + + const p = await prisma.personality.create({ + data: { name: 'grumpy', description: 'curmudgeonly', agentId: agent.id }, + }); + expect(p.id).toBeDefined(); + expect(p.priority).toBe(5); + }); + + it('enforces (name, agentId) uniqueness on personalities', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-pers-uniq'); + const agent = await makeAgent({ name: 'pers-uniq', llmId: llm.id, ownerId: user.id }); + + await prisma.personality.create({ data: { name: 'mode', agentId: agent.id } }); + await expect( + prisma.personality.create({ data: { name: 'mode', agentId: agent.id } }), + ).rejects.toThrow(); + }); + + it('cascade-deletes personalities + bindings when the agent is removed', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-pers-cascade'); + const agent = await makeAgent({ name: 'kaboom', llmId: llm.id, ownerId: user.id }); + + const personality = await prisma.personality.create({ + data: { name: 'snarky', agentId: agent.id }, + }); + const prompt = await prisma.prompt.create({ + data: { name: 'tone', content: 'be snarky', agentId: agent.id }, + }); + await prisma.personalityPrompt.create({ + data: { personalityId: personality.id, promptId: prompt.id }, + }); + + await prisma.agent.delete({ where: { id: agent.id } }); + + expect(await prisma.personality.count({ where: { id: personality.id } })).toBe(0); + expect(await prisma.personalityPrompt.count({ where: { personalityId: personality.id } })).toBe(0); + // Prompts directly attached to the agent also go via Prompt.agentId cascade. + expect(await prisma.prompt.count({ where: { id: prompt.id } })).toBe(0); + }); + + it('SetNull on Agent.defaultPersonalityId when the personality is deleted', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-default-pers'); + const agent = await makeAgent({ name: 'has-default', llmId: llm.id, ownerId: user.id }); + const personality = await prisma.personality.create({ + data: { name: 'def', agentId: agent.id }, + }); + await prisma.agent.update({ + where: { id: agent.id }, + data: { defaultPersonalityId: personality.id }, + }); + + await prisma.personality.delete({ where: { id: personality.id } }); + + const reloaded = await prisma.agent.findUnique({ where: { id: agent.id } }); + expect(reloaded?.defaultPersonalityId).toBeNull(); + }); + + it('binds the same prompt to multiple personalities of an agent', async () => { + const user = await makeUser(); + const llm = await makeLlm('llm-shared-prompt'); + const agent = await makeAgent({ name: 'shared', llmId: llm.id, ownerId: user.id }); + const p1 = await prisma.personality.create({ data: { name: 'a', agentId: agent.id } }); + const p2 = await prisma.personality.create({ data: { name: 'b', agentId: agent.id } }); + const prompt = await prisma.prompt.create({ + data: { name: 'shared-text', content: 'reusable', agentId: agent.id }, + }); + + await prisma.personalityPrompt.create({ + data: { personalityId: p1.id, promptId: prompt.id, priority: 9 }, + }); + await prisma.personalityPrompt.create({ + data: { personalityId: p2.id, promptId: prompt.id, priority: 1 }, + }); + + expect(await prisma.personalityPrompt.count({ where: { promptId: prompt.id } })).toBe(2); + + // Same (personalityId, promptId) cannot collide. + await expect( + prisma.personalityPrompt.create({ + data: { personalityId: p1.id, promptId: prompt.id }, + }), + ).rejects.toThrow(); + }); }); diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index ed742a2..7083b75 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -32,6 +32,10 @@ export async function clearAllTables(client: PrismaClient): Promise { await client.auditLog.deleteMany(); await client.chatMessage.deleteMany(); await client.chatThread.deleteMany(); + await client.personalityPrompt.deleteMany(); + // Break Agent.defaultPersonalityId before personalities can be removed. + await client.agent.updateMany({ data: { defaultPersonalityId: null } }); + await client.personality.deleteMany(); await client.agent.deleteMany(); await client.llm.deleteMany(); await client.mcpInstance.deleteMany(); -- 2.49.1 From 6b5bd78cfa1c7bad620fec09424535431523b83c Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:20:51 +0100 Subject: [PATCH 2/6] feat(mcpd): personality + prompt-by-agent repos and services (Stage 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the schema landed in Stage 1 into the service layer. No HTTP routes yet — Stage 3 will register `/api/v1/...` endpoints and update chat.service to read agent-direct + personality prompts when building the system block. Repositories: - PersonalityRepository: CRUD + listPrompts/attach/detach bindings. - PromptRepository: findByAgent + findByNameAndAgent; create/update accept the new agentId column. findGlobal now also filters agentId=null so agent-direct prompts don't leak into global lists. - AgentRepository: defaultPersonalityId on create + connect/disconnect in update. Services: - PersonalityService: CRUD scoped per agent, plus attach/detach with scope enforcement — a prompt may bind only if it's agent-direct on the same agent, in the agent's project, or global. Foreign-project / foreign-agent attachments are rejected with 400. - PromptService: createPrompt / upsertByName accept agentId and resolve `agent: `, with XOR-with-project guard. Adds listPromptsForAgent. - AgentService: defaultPersonality (by name on the agent's own personality set) round-trips through update + AgentView. Validation: - prompt.schema.ts: refine() rejects projectId+agentId together. - personality.schema.ts: new Create/Update/AttachPrompt schemas. - agent.schema.ts: defaultPersonality { name } | null on update. Tests: 12 PersonalityService + 7 PromptService agent-scope tests covering happy paths, XOR/scope enforcement, double-attach guard, detach-not-bound. mcpd suite: 796/796 (was 777). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcpd/src/main.ts | 6 +- src/mcpd/src/repositories/agent.repository.ts | 8 + .../repositories/personality.repository.ts | 101 ++++++ .../src/repositories/prompt.repository.ts | 49 ++- src/mcpd/src/services/agent.service.ts | 28 ++ src/mcpd/src/services/personality.service.ts | 206 +++++++++++ src/mcpd/src/services/prompt.service.ts | 45 ++- src/mcpd/src/validation/agent.schema.ts | 6 + src/mcpd/src/validation/personality.schema.ts | 24 ++ src/mcpd/src/validation/prompt.schema.ts | 20 +- src/mcpd/tests/personality-service.test.ts | 337 ++++++++++++++++++ .../tests/services/prompt-agent-scope.test.ts | 286 +++++++++++++++ 12 files changed, 1095 insertions(+), 21 deletions(-) create mode 100644 src/mcpd/src/repositories/personality.repository.ts create mode 100644 src/mcpd/src/services/personality.service.ts create mode 100644 src/mcpd/src/validation/personality.schema.ts create mode 100644 src/mcpd/tests/personality-service.test.ts create mode 100644 src/mcpd/tests/services/prompt-agent-scope.test.ts diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 4e9845c..fe530e2 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -44,6 +44,7 @@ import { registerAgentRoutes } from './routes/agents.js'; import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; +import { PersonalityRepository } from './repositories/personality.repository.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; import { McpServerService, @@ -448,8 +449,9 @@ async function main(): Promise { const promptRequestRepo = new PromptRequestRepository(prisma); const promptRuleRegistry = new ResourceRuleRegistry(); promptRuleRegistry.register(systemPromptVarsRule); - const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry); - const agentService = new AgentService(agentRepo, llmService, projectService); + const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); + const personalityRepo = new PersonalityRepository(prisma); + const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); // ChatService needs the proxy + project repo via the ChatToolDispatcher // bridge. The dispatcher's logger references `app.log`, which is not // constructed until further down — `chatService` itself is built right diff --git a/src/mcpd/src/repositories/agent.repository.ts b/src/mcpd/src/repositories/agent.repository.ts index 3e30fd0..fb5c597 100644 --- a/src/mcpd/src/repositories/agent.repository.ts +++ b/src/mcpd/src/repositories/agent.repository.ts @@ -6,6 +6,7 @@ export interface CreateAgentRepoInput { systemPrompt?: string; llmId: string; projectId?: string | null; + defaultPersonalityId?: string | null; proxyModelName?: string | null; defaultParams?: Record; extras?: Record; @@ -17,6 +18,7 @@ export interface UpdateAgentRepoInput { systemPrompt?: string; llmId?: string; projectId?: string | null; + defaultPersonalityId?: string | null; proxyModelName?: string | null; defaultParams?: Record; extras?: Record; @@ -62,6 +64,7 @@ export class AgentRepository implements IAgentRepository { systemPrompt: data.systemPrompt ?? '', llmId: data.llmId, projectId: data.projectId ?? null, + defaultPersonalityId: data.defaultPersonalityId ?? null, proxyModelName: data.proxyModelName ?? null, defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue, extras: (data.extras ?? {}) as Prisma.InputJsonValue, @@ -82,6 +85,11 @@ export class AgentRepository implements IAgentRepository { ? { disconnect: true } : { connect: { id: data.projectId } }; } + if (data.defaultPersonalityId !== undefined) { + updateData.defaultPersonality = data.defaultPersonalityId === null + ? { disconnect: true } + : { connect: { id: data.defaultPersonalityId } }; + } if (data.proxyModelName !== undefined) { updateData.proxyModelName = data.proxyModelName; } diff --git a/src/mcpd/src/repositories/personality.repository.ts b/src/mcpd/src/repositories/personality.repository.ts new file mode 100644 index 0000000..a44478d --- /dev/null +++ b/src/mcpd/src/repositories/personality.repository.ts @@ -0,0 +1,101 @@ +import type { PrismaClient, Personality, PersonalityPrompt, Prompt } from '@prisma/client'; + +export interface PersonalityCreateInput { + name: string; + description?: string; + agentId: string; + priority?: number; +} + +export interface PersonalityUpdateInput { + description?: string; + priority?: number; +} + +export interface IPersonalityRepository { + findAll(): Promise; + findByAgent(agentId: string): Promise; + findById(id: string): Promise; + findByNameAndAgent(name: string, agentId: string): Promise; + create(data: PersonalityCreateInput): Promise; + update(id: string, data: PersonalityUpdateInput): Promise; + delete(id: string): Promise; + listPrompts(personalityId: string): Promise>; + attachPrompt(personalityId: string, promptId: string, priority?: number): Promise; + detachPrompt(personalityId: string, promptId: string): Promise; + findBinding(personalityId: string, promptId: string): Promise; +} + +export class PersonalityRepository implements IPersonalityRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(): Promise { + return this.prisma.personality.findMany({ orderBy: { name: 'asc' } }); + } + + async findByAgent(agentId: string): Promise { + return this.prisma.personality.findMany({ + where: { agentId }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.personality.findUnique({ where: { id } }); + } + + async findByNameAndAgent(name: string, agentId: string): Promise { + return this.prisma.personality.findUnique({ + where: { name_agentId: { name, agentId } }, + }); + } + + async create(data: PersonalityCreateInput): Promise { + return this.prisma.personality.create({ + data: { + name: data.name, + description: data.description ?? '', + agentId: data.agentId, + priority: data.priority ?? 5, + }, + }); + } + + async update(id: string, data: PersonalityUpdateInput): Promise { + return this.prisma.personality.update({ where: { id }, data }); + } + + async delete(id: string): Promise { + await this.prisma.personality.delete({ where: { id } }); + } + + async listPrompts(personalityId: string): Promise> { + return this.prisma.personalityPrompt.findMany({ + where: { personalityId }, + include: { prompt: true }, + orderBy: { priority: 'desc' }, + }); + } + + async attachPrompt(personalityId: string, promptId: string, priority?: number): Promise { + return this.prisma.personalityPrompt.create({ + data: { + personalityId, + promptId, + priority: priority ?? 5, + }, + }); + } + + async detachPrompt(personalityId: string, promptId: string): Promise { + await this.prisma.personalityPrompt.delete({ + where: { personalityId_promptId: { personalityId, promptId } }, + }); + } + + async findBinding(personalityId: string, promptId: string): Promise { + return this.prisma.personalityPrompt.findUnique({ + where: { personalityId_promptId: { personalityId, promptId } }, + }); + } +} diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts index f60b6fd..80d2511 100644 --- a/src/mcpd/src/repositories/prompt.repository.ts +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -1,12 +1,30 @@ import type { PrismaClient, Prompt } from '@prisma/client'; +export interface PromptCreateInput { + name: string; + content: string; + projectId?: string; + agentId?: string; + priority?: number; + linkTarget?: string; +} + +export interface PromptUpdateInput { + content?: string; + priority?: number; + summary?: string; + chapters?: string[]; +} + export interface IPromptRepository { findAll(projectId?: string): Promise; findGlobal(): Promise; + findByAgent(agentId: string): Promise; findById(id: string): Promise; findByNameAndProject(name: string, projectId: string | null): Promise; - create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise; - update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise; + findByNameAndAgent(name: string, agentId: string | null): Promise; + create(data: PromptCreateInput): Promise; + update(id: string, data: PromptUpdateInput): Promise; delete(id: string): Promise; } @@ -18,7 +36,7 @@ export class PromptRepository implements IPromptRepository { if (projectId !== undefined) { // Project-scoped + global prompts return this.prisma.prompt.findMany({ - where: { OR: [{ projectId }, { projectId: null }] }, + where: { OR: [{ projectId }, { projectId: null, agentId: null }] }, include, orderBy: { name: 'asc' }, }); @@ -28,16 +46,27 @@ export class PromptRepository implements IPromptRepository { async findGlobal(): Promise { return this.prisma.prompt.findMany({ - where: { projectId: null }, + where: { projectId: null, agentId: null }, include: { project: { select: { name: true } } }, orderBy: { name: 'asc' }, }); } + async findByAgent(agentId: string): Promise { + return this.prisma.prompt.findMany({ + where: { agentId }, + include: { agent: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); + } + async findById(id: string): Promise { return this.prisma.prompt.findUnique({ where: { id }, - include: { project: { select: { name: true } } }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, }); } @@ -47,11 +76,17 @@ export class PromptRepository implements IPromptRepository { }); } - async create(data: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string }): Promise { + async findByNameAndAgent(name: string, agentId: string | null): Promise { + return this.prisma.prompt.findUnique({ + where: { name_agentId: { name, agentId: agentId ?? '' } }, + }); + } + + async create(data: PromptCreateInput): Promise { return this.prisma.prompt.create({ data }); } - async update(id: string, data: { content?: string; priority?: number; summary?: string; chapters?: string[] }): Promise { + async update(id: string, data: PromptUpdateInput): Promise { return this.prisma.prompt.update({ where: { id }, data }); } diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index b958367..0be9e5f 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -10,6 +10,7 @@ */ import type { Agent } from '@prisma/client'; import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { LlmService } from './llm.service.js'; import type { ProjectService } from './project.service.js'; import { @@ -28,6 +29,7 @@ export interface AgentView { systemPrompt: string; llm: { id: string; name: string }; project: { id: string; name: string } | null; + defaultPersonality: { id: string; name: string } | null; proxyModelName: string | null; defaultParams: AgentChatParams; extras: Record; @@ -42,6 +44,7 @@ export class AgentService { private readonly repo: IAgentRepository, private readonly llms: LlmService, private readonly projects: ProjectService, + private readonly personalities?: IPersonalityRepository, ) {} async list(): Promise { @@ -107,6 +110,25 @@ export class AgentService { ? null : (await this.projects.resolveAndGet(data.project.name)).id; } + if (data.defaultPersonality !== undefined) { + if (data.defaultPersonality === null) { + updateFields.defaultPersonalityId = null; + } else { + if (this.personalities === undefined) { + throw new Error('PersonalityRepository must be wired into AgentService to set defaultPersonality'); + } + const agent = await this.repo.findById(id); + if (agent === null) throw new NotFoundError(`Agent not found: ${id}`); + const personality = await this.personalities.findByNameAndAgent( + data.defaultPersonality.name, + agent.id, + ); + if (personality === null) { + throw new NotFoundError(`Personality not found on agent ${agent.name}: ${data.defaultPersonality.name}`); + } + updateFields.defaultPersonalityId = personality.id; + } + } if (data.proxyModelName !== undefined) updateFields.proxyModelName = data.proxyModelName; if (data.defaultParams !== undefined) updateFields.defaultParams = data.defaultParams as Record; if (data.extras !== undefined) updateFields.extras = data.extras; @@ -141,6 +163,11 @@ export class AgentService { const project = row.projectId !== null ? await this.projects.getById(row.projectId).catch(() => null) : null; + let defaultPersonality: { id: string; name: string } | null = null; + if (row.defaultPersonalityId !== null && this.personalities !== undefined) { + const p = await this.personalities.findById(row.defaultPersonalityId); + if (p !== null) defaultPersonality = { id: p.id, name: p.name }; + } return { id: row.id, name: row.name, @@ -148,6 +175,7 @@ export class AgentService { systemPrompt: row.systemPrompt, llm: { id: llm.id, name: llm.name }, project: project !== null ? { id: project.id, name: project.name } : null, + defaultPersonality, proxyModelName: row.proxyModelName, defaultParams: row.defaultParams as AgentChatParams, extras: row.extras as Record, diff --git a/src/mcpd/src/services/personality.service.ts b/src/mcpd/src/services/personality.service.ts new file mode 100644 index 0000000..d6812cf --- /dev/null +++ b/src/mcpd/src/services/personality.service.ts @@ -0,0 +1,206 @@ +/** + * PersonalityService — CRUD over `Personality` rows + prompt bindings. + * + * A Personality is a named overlay attached to one Agent. At chat time, the + * caller may pick a Personality (by `--personality` flag, or by the agent's + * `defaultPersonalityId`); the prompts bound to that Personality are appended + * to the system block on top of the agent's own systemPrompt + agent-direct + * prompts + project prompts. VLAN-on-ethernet: the agent works without one; + * with one, segmentation kicks in. + * + * Scope rule for attaching a Prompt to a Personality (enforced here, not in + * the DB, so error messages stay readable): + * A prompt may be bound only if it's: + * - directly attached to the same Agent (Prompt.agentId === personality.agentId), OR + * - in the Agent's Project (Prompt.projectId === agent.projectId, when projectId set), OR + * - global (Prompt.projectId === null && Prompt.agentId === null). + * Attaching a prompt from a foreign project / foreign agent is rejected. + */ +import type { Personality, PersonalityPrompt, Prompt } from '@prisma/client'; +import type { IPersonalityRepository } from '../repositories/personality.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { IPromptRepository } from '../repositories/prompt.repository.js'; +import { + CreatePersonalitySchema, + UpdatePersonalitySchema, + AttachPromptSchema, +} from '../validation/personality.schema.js'; +import { NotFoundError, ConflictError } from './mcp-server.service.js'; + +export interface PersonalityView { + id: string; + name: string; + description: string; + agentId: string; + agentName: string; + priority: number; + promptCount: number; + createdAt: Date; + updatedAt: Date; +} + +export interface PersonalityPromptView { + promptId: string; + promptName: string; + promptContent: string; + priority: number; // PersonalityPrompt-scoped priority (overrides Prompt.priority within this overlay) + createdAt: Date; +} + +export class PersonalityService { + constructor( + private readonly repo: IPersonalityRepository, + private readonly agentRepo: IAgentRepository, + private readonly promptRepo: IPromptRepository, + ) {} + + async listForAgent(agentName: string): Promise { + const agent = await this.agentRepo.findByName(agentName); + if (agent === null) throw new NotFoundError(`Agent not found: ${agentName}`); + const rows = await this.repo.findByAgent(agent.id); + return Promise.all(rows.map((r) => this.toView(r, agent.name))); + } + + async getById(id: string): Promise { + const row = await this.repo.findById(id); + if (row === null) throw new NotFoundError(`Personality not found: ${id}`); + const agent = await this.agentRepo.findById(row.agentId); + return this.toView(row, agent?.name ?? row.agentId); + } + + async create(agentName: string, input: unknown): Promise { + const agent = await this.agentRepo.findByName(agentName); + if (agent === null) throw new NotFoundError(`Agent not found: ${agentName}`); + + // Inject the resolved agentId so the schema can validate the full shape + // (callers pass `{ name, description?, priority? }` without re-stating it). + const data = CreatePersonalitySchema.parse({ + ...(input as Record), + agentId: agent.id, + }); + + const existing = await this.repo.findByNameAndAgent(data.name, agent.id); + if (existing !== null) { + throw new ConflictError(`Personality already exists: ${agentName}/${data.name}`); + } + + const created = await this.repo.create({ + name: data.name, + description: data.description, + agentId: agent.id, + priority: data.priority, + }); + return this.toView(created, agent.name); + } + + async update(id: string, input: unknown): Promise { + const data = UpdatePersonalitySchema.parse(input); + const existing = await this.repo.findById(id); + if (existing === null) throw new NotFoundError(`Personality not found: ${id}`); + + const updateFields: Parameters[1] = {}; + if (data.description !== undefined) updateFields.description = data.description; + if (data.priority !== undefined) updateFields.priority = data.priority; + + const updated = await this.repo.update(id, updateFields); + const agent = await this.agentRepo.findById(updated.agentId); + return this.toView(updated, agent?.name ?? updated.agentId); + } + + async delete(id: string): Promise { + const existing = await this.repo.findById(id); + if (existing === null) throw new NotFoundError(`Personality not found: ${id}`); + await this.repo.delete(id); + } + + // ── Prompt bindings ── + + async listBoundPrompts(personalityId: string): Promise { + const personality = await this.repo.findById(personalityId); + if (personality === null) throw new NotFoundError(`Personality not found: ${personalityId}`); + + const rows = await this.repo.listPrompts(personalityId); + return rows.map((r) => ({ + promptId: r.prompt.id, + promptName: r.prompt.name, + promptContent: r.prompt.content, + priority: r.priority, + createdAt: r.createdAt, + })); + } + + async attachPrompt(personalityId: string, input: unknown): Promise { + const data = AttachPromptSchema.parse(input); + const personality = await this.repo.findById(personalityId); + if (personality === null) throw new NotFoundError(`Personality not found: ${personalityId}`); + + const prompt = await this.promptRepo.findById(data.promptId); + if (prompt === null) throw new NotFoundError(`Prompt not found: ${data.promptId}`); + + await this.assertPromptInScope(prompt, personality); + + const dup = await this.repo.findBinding(personalityId, data.promptId); + if (dup !== null) { + throw new ConflictError(`Prompt already bound to personality: ${prompt.name}`); + } + + return this.repo.attachPrompt(personalityId, data.promptId, data.priority); + } + + async detachPrompt(personalityId: string, promptId: string): Promise { + const personality = await this.repo.findById(personalityId); + if (personality === null) throw new NotFoundError(`Personality not found: ${personalityId}`); + const binding = await this.repo.findBinding(personalityId, promptId); + if (binding === null) { + throw new NotFoundError(`Prompt not bound to personality: ${promptId}`); + } + await this.repo.detachPrompt(personalityId, promptId); + } + + /** + * A prompt may overlay a personality only if it shares scope with the + * agent — direct attachment, the agent's project, or global. Anything else + * is a leak attempt (e.g., someone trying to attach Project A's prompts to + * Project B's agent's personality). + */ + private async assertPromptInScope(prompt: Prompt, personality: Personality): Promise { + if (prompt.agentId !== null) { + if (prompt.agentId !== personality.agentId) { + throw Object.assign( + new Error(`Prompt belongs to a different agent and cannot be attached to this personality`), + { statusCode: 400 }, + ); + } + return; + } + if (prompt.projectId !== null) { + const agent = await this.agentRepo.findById(personality.agentId); + if (agent === null) { + throw new NotFoundError(`Agent not found: ${personality.agentId}`); + } + if (agent.projectId !== prompt.projectId) { + throw Object.assign( + new Error(`Prompt belongs to a different project and cannot be attached to this personality`), + { statusCode: 400 }, + ); + } + return; + } + // Global prompt — allowed for any personality. + } + + private async toView(row: Personality, agentName: string): Promise { + const prompts = await this.repo.listPrompts(row.id); + return { + id: row.id, + name: row.name, + description: row.description, + agentId: row.agentId, + agentName, + priority: row.priority, + promptCount: prompts.length, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; + } +} diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index 408ab8a..528826a 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -2,6 +2,7 @@ import type { Prompt, PromptRequest } from '@prisma/client'; import type { IPromptRepository } from '../repositories/prompt.repository.js'; import type { IPromptRequestRepository } from '../repositories/prompt-request.repository.js'; import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; import { CreatePromptSchema, UpdatePromptSchema, CreatePromptRequestSchema, UpdatePromptRequestSchema } from '../validation/prompt.schema.js'; import { NotFoundError } from './mcp-server.service.js'; import type { PromptSummaryService } from './prompt-summary.service.js'; @@ -16,6 +17,7 @@ export class PromptService { private readonly promptRequestRepo: IPromptRequestRepository, private readonly projectRepo: IProjectRepository, private readonly ruleRegistry?: ResourceRuleRegistry, + private readonly agentRepo?: IAgentRepository, ) {} setSummaryService(service: PromptSummaryService): void { @@ -66,6 +68,10 @@ export class PromptService { return this.promptRepo.findGlobal(); } + async listPromptsForAgent(agentId: string): Promise { + return this.promptRepo.findByAgent(agentId); + } + async getPrompt(id: string): Promise { const prompt = await this.promptRepo.findById(id); if (prompt === null) throw new NotFoundError(`Prompt not found: ${id}`); @@ -75,18 +81,26 @@ export class PromptService { async createPrompt(input: unknown): Promise { const data = CreatePromptSchema.parse(input); - if (data.projectId) { + if (data.projectId !== undefined) { const project = await this.projectRepo.findById(data.projectId); if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); } + if (data.agentId !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped prompts require AgentRepository to be wired into PromptService'); + } + const agent = await this.agentRepo.findById(data.agentId); + if (agent === null) throw new NotFoundError(`Agent not found: ${data.agentId}`); + } await this.validatePromptRules(data.name, data.content, data.projectId, 'create'); - const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = { + const createData: { name: string; content: string; projectId?: string; agentId?: string; priority?: number; linkTarget?: string } = { name: data.name, content: data.content, }; if (data.projectId !== undefined) createData.projectId = data.projectId; + if (data.agentId !== undefined) createData.agentId = data.agentId; if (data.priority !== undefined) createData.priority = data.priority; if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; const prompt = await this.promptRepo.create(createData); @@ -223,8 +237,8 @@ export class PromptService { async upsertByName(data: Record): Promise { const name = data['name'] as string; let projectId: string | null = null; + let agentId: string | null = null; - // Resolve project name to ID if provided if (data['project'] !== undefined) { const project = await this.projectRepo.findByName(data['project'] as string); if (project === null) throw new NotFoundError(`Project not found: ${data['project']}`); @@ -233,7 +247,27 @@ export class PromptService { projectId = data['projectId'] as string; } - const existing = await this.promptRepo.findByNameAndProject(name, projectId); + if (data['agent'] !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped prompts require AgentRepository to be wired into PromptService'); + } + const agent = await this.agentRepo.findByName(data['agent'] as string); + if (agent === null) throw new NotFoundError(`Agent not found: ${data['agent']}`); + agentId = agent.id; + } else if (data['agentId'] !== undefined) { + agentId = data['agentId'] as string; + } + + if (projectId !== null && agentId !== null) { + throw Object.assign( + new Error('A prompt may attach to a project XOR an agent, not both'), + { statusCode: 400 }, + ); + } + + const existing = agentId !== null + ? await this.promptRepo.findByNameAndAgent(name, agentId) + : await this.promptRepo.findByNameAndProject(name, projectId); if (existing !== null) { const updateData: { content?: string; priority?: number } = {}; @@ -245,11 +279,12 @@ export class PromptService { return existing; } - const createData: { name: string; content: string; projectId?: string; priority?: number; linkTarget?: string } = { + const createData: { name: string; content: string; projectId?: string; agentId?: string; priority?: number; linkTarget?: string } = { name, content: (data['content'] as string) ?? '', }; if (projectId !== null) createData.projectId = projectId; + if (agentId !== null) createData.agentId = agentId; if (data['priority'] !== undefined) createData.priority = data['priority'] as number; if (data['linkTarget'] !== undefined) createData.linkTarget = data['linkTarget'] as string; diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index 27c4de7..5f253c0 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -61,6 +61,11 @@ const LlmRefSchema = z.union([ z.object({ id: z.string().min(1) }), ]); const ProjectRefSchema = z.object({ name: z.string().min(1) }); +/** + * Personality reference is by name only (per-agent unique constraint). + * `null` clears the agent's defaultPersonalityId on update. + */ +const PersonalityRefSchema = z.object({ name: z.string().min(1) }); const NAME_RE = /^[a-z0-9-]+$/; @@ -84,6 +89,7 @@ export const UpdateAgentSchema = z.object({ systemPrompt: z.string().max(64_000).optional(), llm: LlmRefSchema.optional(), project: ProjectRefSchema.nullable().optional(), + defaultPersonality: PersonalityRefSchema.nullable().optional(), proxyModelName: z.string().min(1).nullable().optional(), defaultParams: AgentChatParamsSchema.optional(), extras: z.record(z.unknown()).optional(), diff --git a/src/mcpd/src/validation/personality.schema.ts b/src/mcpd/src/validation/personality.schema.ts new file mode 100644 index 0000000..0a5060c --- /dev/null +++ b/src/mcpd/src/validation/personality.schema.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +const NAME_RE = /^[a-z0-9-]+$/; + +export const CreatePersonalitySchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + description: z.string().max(2000).default(''), + agentId: z.string().min(1), + priority: z.number().int().min(1).max(10).default(5), +}); + +export const UpdatePersonalitySchema = z.object({ + description: z.string().max(2000).optional(), + priority: z.number().int().min(1).max(10).optional(), +}); + +export const AttachPromptSchema = z.object({ + promptId: z.string().min(1), + priority: z.number().int().min(1).max(10).optional(), +}); + +export type CreatePersonalityInput = z.infer; +export type UpdatePersonalityInput = z.infer; +export type AttachPromptInput = z.infer; diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts index f1b760e..dc1e56c 100644 --- a/src/mcpd/src/validation/prompt.schema.ts +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -2,13 +2,19 @@ import { z } from 'zod'; const LINK_TARGET_RE = /^[a-z0-9-]+\/[a-z0-9-]+:\S+$/; -export const CreatePromptSchema = z.object({ - name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), - content: z.string().min(1).max(50000), - projectId: z.string().optional(), - priority: z.number().int().min(1).max(10).default(5).optional(), - linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').optional(), -}); +export const CreatePromptSchema = z + .object({ + name: z.string().min(1).max(100).regex(/^[a-z0-9-]+$/, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(50000), + projectId: z.string().optional(), + agentId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + linkTarget: z.string().regex(LINK_TARGET_RE, 'Link target must be project/server:resource-uri').optional(), + }) + .refine( + (data) => !(data.projectId !== undefined && data.agentId !== undefined), + { message: 'A prompt may attach to a project XOR an agent, not both', path: ['agentId'] }, + ); export const UpdatePromptSchema = z.object({ content: z.string().min(1).max(50000).optional(), diff --git a/src/mcpd/tests/personality-service.test.ts b/src/mcpd/tests/personality-service.test.ts new file mode 100644 index 0000000..0b6f89d --- /dev/null +++ b/src/mcpd/tests/personality-service.test.ts @@ -0,0 +1,337 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PersonalityService } from '../src/services/personality.service.js'; +import type { IPersonalityRepository } from '../src/repositories/personality.repository.js'; +import type { IAgentRepository } from '../src/repositories/agent.repository.js'; +import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; +import type { Agent, Personality, PersonalityPrompt, Prompt } from '@prisma/client'; + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-1', + name: 'reviewer', + description: '', + systemPrompt: '', + llmId: 'llm-1', + projectId: null, + defaultPersonalityId: null, + proxyModelName: null, + defaultParams: {} as Agent['defaultParams'], + extras: {} as Agent['extras'], + ownerId: 'owner-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makePersonality(overrides: Partial = {}): Personality { + return { + id: `pers-${Math.random().toString(36).slice(2, 8)}`, + name: 'grumpy', + description: '', + agentId: 'agent-1', + priority: 5, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makePrompt(overrides: Partial = {}): Prompt { + return { + id: `prompt-${Math.random().toString(36).slice(2, 8)}`, + name: 'p', + content: 'c', + projectId: null, + agentId: null, + priority: 5, + summary: null, + chapters: null, + linkTarget: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockPersonalityRepo(initial: Personality[] = []): IPersonalityRepository { + const rows = new Map(initial.map((r) => [r.id, r])); + const bindings = new Map(); + const key = (pid: string, prid: string): string => `${pid}::${prid}`; + return { + findAll: vi.fn(async () => [...rows.values()]), + findByAgent: vi.fn(async (agentId: string) => + [...rows.values()].filter((r) => r.agentId === agentId)), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByNameAndAgent: vi.fn(async (name: string, agentId: string) => { + for (const r of rows.values()) { + if (r.name === name && r.agentId === agentId) return r; + } + return null; + }), + create: vi.fn(async (data) => { + const row = makePersonality({ + id: `pers-${String(rows.size + 1)}`, + name: data.name, + description: data.description ?? '', + agentId: data.agentId, + priority: data.priority ?? 5, + }); + rows.set(row.id, row); + return row; + }), + update: vi.fn(async (id, data) => { + const existing = rows.get(id); + if (!existing) throw new Error('not found'); + const next: Personality = { + ...existing, + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.priority !== undefined ? { priority: data.priority } : {}), + }; + rows.set(id, next); + return next; + }), + delete: vi.fn(async (id: string) => { + rows.delete(id); + // Cascade-emulate: drop bindings whose personality is gone. + for (const k of [...bindings.keys()]) { + if (k.startsWith(`${id}::`)) bindings.delete(k); + } + }), + listPrompts: vi.fn(async (personalityId: string) => + [...bindings.values()] + .filter((b) => b.personalityId === personalityId) + .map((b) => ({ + ...b, + prompt: makePrompt({ id: b.promptId, name: `prompt-${b.promptId}` }), + }))), + attachPrompt: vi.fn(async (personalityId: string, promptId: string, priority?: number) => { + const binding: PersonalityPrompt = { + id: `bind-${bindings.size + 1}`, + personalityId, + promptId, + priority: priority ?? 5, + createdAt: new Date(), + }; + bindings.set(key(personalityId, promptId), binding); + return binding; + }), + detachPrompt: vi.fn(async (personalityId: string, promptId: string) => { + bindings.delete(key(personalityId, promptId)); + }), + findBinding: vi.fn(async (personalityId: string, promptId: string) => + bindings.get(key(personalityId, promptId)) ?? null), + }; +} + +function mockAgentRepo(agents: Agent[] = []): IAgentRepository { + const rows = new Map(agents.map((r) => [r.id, r])); + return { + findAll: vi.fn(async () => [...rows.values()]), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByName: vi.fn(async (name: string) => { + for (const r of rows.values()) if (r.name === name) return r; + return null; + }), + findByProjectId: vi.fn(async (projectId: string) => + [...rows.values()].filter((r) => r.projectId === projectId)), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +function mockPromptRepo(prompts: Prompt[] = []): IPromptRepository { + const rows = new Map(prompts.map((p) => [p.id, p])); + return { + findAll: vi.fn(async () => [...rows.values()]), + findGlobal: vi.fn(async () => + [...rows.values()].filter((p) => p.projectId === null && p.agentId === null)), + findByAgent: vi.fn(async (agentId: string) => + [...rows.values()].filter((p) => p.agentId === agentId)), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByNameAndProject: vi.fn(), + findByNameAndAgent: vi.fn(), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +describe('PersonalityService', () => { + it('creates a personality bound to an agent', async () => { + const agent = makeAgent(); + const repo = mockPersonalityRepo(); + const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo()); + + const p = await service.create('reviewer', { name: 'grumpy', priority: 7 }); + + expect(p.name).toBe('grumpy'); + expect(p.agentName).toBe('reviewer'); + expect(p.priority).toBe(7); + expect(p.promptCount).toBe(0); + }); + + it('rejects create when the agent does not exist', async () => { + const service = new PersonalityService( + mockPersonalityRepo(), + mockAgentRepo([]), + mockPromptRepo(), + ); + await expect(service.create('ghost', { name: 'x' })).rejects.toThrow(/Agent not found/); + }); + + it('rejects duplicate (name, agent) personalities', async () => { + const agent = makeAgent(); + const repo = mockPersonalityRepo([ + makePersonality({ id: 'pers-existing', name: 'grumpy', agentId: 'agent-1' }), + ]); + const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo()); + + await expect(service.create('reviewer', { name: 'grumpy' })).rejects.toThrow(/already exists/); + }); + + it('lists personalities for an agent', async () => { + const agent = makeAgent(); + const repo = mockPersonalityRepo([ + makePersonality({ id: 'p1', name: 'a', agentId: 'agent-1' }), + makePersonality({ id: 'p2', name: 'b', agentId: 'agent-1' }), + makePersonality({ id: 'p3', name: 'c', agentId: 'other-agent' }), + ]); + const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo()); + + const list = await service.listForAgent('reviewer'); + expect(list.map((p) => p.name).sort()).toEqual(['a', 'b']); + }); + + it('updates description + priority', async () => { + const agent = makeAgent(); + const personality = makePersonality({ id: 'pers-up', description: 'old', priority: 3 }); + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService(repo, mockAgentRepo([agent]), mockPromptRepo()); + + const updated = await service.update('pers-up', { description: 'new', priority: 9 }); + expect(updated.description).toBe('new'); + expect(updated.priority).toBe(9); + }); + + it('attaches an agent-direct prompt', async () => { + const agent = makeAgent(); + const personality = makePersonality({ id: 'pers-1', agentId: agent.id }); + const prompt = makePrompt({ id: 'pr-1', agentId: agent.id }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([prompt]), + ); + + const binding = await service.attachPrompt('pers-1', { promptId: 'pr-1', priority: 8 }); + expect(binding.priority).toBe(8); + }); + + it('attaches a prompt from the agent\'s project', async () => { + const agent = makeAgent({ projectId: 'proj-1' }); + const personality = makePersonality({ id: 'pers-2', agentId: agent.id }); + const prompt = makePrompt({ id: 'pr-proj', projectId: 'proj-1' }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([prompt]), + ); + + const binding = await service.attachPrompt('pers-2', { promptId: 'pr-proj' }); + expect(binding.promptId).toBe('pr-proj'); + }); + + it('attaches a global prompt (projectId=null, agentId=null)', async () => { + const agent = makeAgent(); + const personality = makePersonality({ id: 'pers-3', agentId: agent.id }); + const prompt = makePrompt({ id: 'pr-global' }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([prompt]), + ); + + const binding = await service.attachPrompt('pers-3', { promptId: 'pr-global' }); + expect(binding.promptId).toBe('pr-global'); + }); + + it('rejects attaching a prompt that belongs to a different agent', async () => { + const agent = makeAgent({ id: 'agent-1' }); + const personality = makePersonality({ id: 'pers-4', agentId: 'agent-1' }); + const foreign = makePrompt({ id: 'pr-foreign', agentId: 'agent-other' }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([foreign]), + ); + + await expect( + service.attachPrompt('pers-4', { promptId: 'pr-foreign' }), + ).rejects.toThrow(/different agent/); + }); + + it('rejects attaching a prompt from a foreign project', async () => { + const agent = makeAgent({ projectId: 'proj-1' }); + const personality = makePersonality({ id: 'pers-5', agentId: agent.id }); + const foreign = makePrompt({ id: 'pr-other-proj', projectId: 'proj-other' }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([foreign]), + ); + + await expect( + service.attachPrompt('pers-5', { promptId: 'pr-other-proj' }), + ).rejects.toThrow(/different project/); + }); + + it('rejects double-attach of the same prompt', async () => { + const agent = makeAgent(); + const personality = makePersonality({ id: 'pers-dup', agentId: agent.id }); + const prompt = makePrompt({ id: 'pr-dup', agentId: agent.id }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([prompt]), + ); + + await service.attachPrompt('pers-dup', { promptId: 'pr-dup' }); + await expect( + service.attachPrompt('pers-dup', { promptId: 'pr-dup' }), + ).rejects.toThrow(/already bound/); + }); + + it('detaches a prompt and rejects detaching one that was never bound', async () => { + const agent = makeAgent(); + const personality = makePersonality({ id: 'pers-det', agentId: agent.id }); + const prompt = makePrompt({ id: 'pr-det', agentId: agent.id }); + + const repo = mockPersonalityRepo([personality]); + const service = new PersonalityService( + repo, + mockAgentRepo([agent]), + mockPromptRepo([prompt]), + ); + + await service.attachPrompt('pers-det', { promptId: 'pr-det' }); + await service.detachPrompt('pers-det', 'pr-det'); + + await expect(service.detachPrompt('pers-det', 'pr-det')).rejects.toThrow(/not bound/); + }); +}); diff --git a/src/mcpd/tests/services/prompt-agent-scope.test.ts b/src/mcpd/tests/services/prompt-agent-scope.test.ts new file mode 100644 index 0000000..146d7bd --- /dev/null +++ b/src/mcpd/tests/services/prompt-agent-scope.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PromptService } from '../../src/services/prompt.service.js'; +import type { IPromptRepository } from '../../src/repositories/prompt.repository.js'; +import type { IPromptRequestRepository } from '../../src/repositories/prompt-request.repository.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import type { IAgentRepository } from '../../src/repositories/agent.repository.js'; +import type { Prompt, Agent } from '@prisma/client'; + +/** + * Coverage for the new "Prompt.agentId" path: creating a prompt scoped to an + * agent, listing prompts by agent, XOR-with-project enforcement at the schema + * level, and the upsert path that resolves agent name → id. + */ + +function makePrompt(overrides: Partial = {}): Prompt { + return { + id: 'prompt-1', + name: 'p', + content: 'c', + projectId: null, + agentId: null, + priority: 5, + summary: null, + chapters: null, + linkTarget: null, + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function makeAgent(overrides: Partial = {}): Agent { + return { + id: 'agent-1', + name: 'reviewer', + description: '', + systemPrompt: '', + llmId: 'llm-1', + projectId: null, + defaultPersonalityId: null, + proxyModelName: null, + defaultParams: {} as Agent['defaultParams'], + extras: {} as Agent['extras'], + ownerId: 'owner-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} + +function mockPromptRepo(): IPromptRepository { + const rows = new Map(); + return { + findAll: vi.fn(async () => [...rows.values()]), + findGlobal: vi.fn(async () => + [...rows.values()].filter((p) => p.projectId === null && p.agentId === null)), + findByAgent: vi.fn(async (agentId: string) => + [...rows.values()].filter((p) => p.agentId === agentId)), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByNameAndProject: vi.fn(async (name: string, projectId: string | null) => { + for (const p of rows.values()) { + if (p.name === name && (p.projectId ?? null) === projectId) return p; + } + return null; + }), + findByNameAndAgent: vi.fn(async (name: string, agentId: string | null) => { + for (const p of rows.values()) { + if (p.name === name && (p.agentId ?? null) === agentId) return p; + } + return null; + }), + create: vi.fn(async (data) => { + const row = makePrompt({ + id: `prompt-${rows.size + 1}`, + name: data.name, + content: data.content, + projectId: data.projectId ?? null, + agentId: data.agentId ?? null, + priority: data.priority ?? 5, + linkTarget: data.linkTarget ?? null, + }); + rows.set(row.id, row); + return row; + }), + update: vi.fn(async (id, data) => { + const existing = rows.get(id); + if (!existing) throw new Error('not found'); + const next = { ...existing, ...data } as Prompt; + rows.set(id, next); + return next; + }), + delete: vi.fn(async (id: string) => { rows.delete(id); }), + }; +} + +function mockPromptRequestRepo(): IPromptRequestRepository { + return { + findAll: vi.fn(async () => []), + findGlobal: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByNameAndProject: vi.fn(async () => null), + findBySession: vi.fn(async () => []), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +function mockProjectRepo(): IProjectRepository { + return { + findAll: vi.fn(async () => []), + findById: vi.fn(async () => null), + findByName: vi.fn(async () => null), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +function mockAgentRepo(initial: Agent[]): IAgentRepository { + const rows = new Map(initial.map((a) => [a.id, a])); + return { + findAll: vi.fn(async () => [...rows.values()]), + findById: vi.fn(async (id: string) => rows.get(id) ?? null), + findByName: vi.fn(async (name: string) => { + for (const a of rows.values()) if (a.name === name) return a; + return null; + }), + findByProjectId: vi.fn(async () => []), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + }; +} + +describe('PromptService — agent-direct scope', () => { + it('creates a prompt directly attached to an agent', async () => { + const promptRepo = mockPromptRepo(); + const agentRepo = mockAgentRepo([makeAgent()]); + const service = new PromptService( + promptRepo, + mockPromptRequestRepo(), + mockProjectRepo(), + undefined, + agentRepo, + ); + + const prompt = await service.createPrompt({ + name: 'agent-only', + content: 'hi', + agentId: 'agent-1', + }); + + expect(prompt.agentId).toBe('agent-1'); + expect(prompt.projectId).toBeNull(); + }); + + it('rejects create when both projectId and agentId are set', async () => { + const service = new PromptService( + mockPromptRepo(), + mockPromptRequestRepo(), + mockProjectRepo(), + undefined, + mockAgentRepo([makeAgent()]), + ); + + await expect( + service.createPrompt({ + name: 'bad', + content: 'c', + projectId: 'proj-1', + agentId: 'agent-1', + }), + ).rejects.toThrow(); + }); + + it('rejects create when the agent does not exist', async () => { + const service = new PromptService( + mockPromptRepo(), + mockPromptRequestRepo(), + mockProjectRepo(), + undefined, + mockAgentRepo([]), + ); + + await expect( + service.createPrompt({ name: 'orphan', content: 'c', agentId: 'agent-ghost' }), + ).rejects.toThrow(/Agent not found/); + }); + + it('refuses agent-scoped create when AgentRepository is not wired', async () => { + // Mirrors the "agentRepo: undefined" wiring that older callers may use. + const service = new PromptService( + mockPromptRepo(), + mockPromptRequestRepo(), + mockProjectRepo(), + ); + + await expect( + service.createPrompt({ name: 'oops', content: 'c', agentId: 'agent-1' }), + ).rejects.toThrow(/AgentRepository/); + }); + + it('lists prompts directly attached to an agent', async () => { + const promptRepo = mockPromptRepo(); + const agentRepo = mockAgentRepo([makeAgent()]); + const service = new PromptService( + promptRepo, + mockPromptRequestRepo(), + mockProjectRepo(), + undefined, + agentRepo, + ); + + await service.createPrompt({ name: 'a', content: 'A', agentId: 'agent-1' }); + await service.createPrompt({ name: 'b', content: 'B', agentId: 'agent-1' }); + // Different agent — should NOT show up. + const otherAgentRepo = mockAgentRepo([makeAgent({ id: 'agent-other', name: 'other' })]); + const otherService = new PromptService( + promptRepo, + mockPromptRequestRepo(), + mockProjectRepo(), + undefined, + otherAgentRepo, + ); + await otherService.createPrompt({ name: 'c', content: 'C', agentId: 'agent-other' }); + + const list = await service.listPromptsForAgent('agent-1'); + expect(list.map((p) => p.name).sort()).toEqual(['a', 'b']); + }); + + it('upserts by name with agent scope', async () => { + const promptRepo = mockPromptRepo(); + const agentRepo = mockAgentRepo([makeAgent()]); + const service = new PromptService( + promptRepo, + mockPromptRequestRepo(), + mockProjectRepo(), + undefined, + agentRepo, + ); + + const first = await service.upsertByName({ + name: 'tone', + content: 'be polite', + agent: 'reviewer', + }); + expect(first.agentId).toBe('agent-1'); + + const second = await service.upsertByName({ + name: 'tone', + content: 'be terse', + agent: 'reviewer', + }); + expect(second.id).toBe(first.id); + expect(second.content).toBe('be terse'); + }); + + it('upsert rejects when both project and agent are provided', async () => { + // Project must resolve (otherwise the service throws "Project not found" + // before reaching the XOR check). Make findByName return a project. + const projectRepo = mockProjectRepo(); + vi.mocked(projectRepo.findByName).mockResolvedValue({ + id: 'proj-1', name: 'p', + } as Awaited>); + + const service = new PromptService( + mockPromptRepo(), + mockPromptRequestRepo(), + projectRepo, + undefined, + mockAgentRepo([makeAgent()]), + ); + + await expect( + service.upsertByName({ + name: 'x', + content: 'c', + project: 'p', + agent: 'reviewer', + }), + ).rejects.toThrow(/XOR/); + }); +}); -- 2.49.1 From faef1e732d771e7a398480e15aab35513b4aaad7 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:27:59 +0100 Subject: [PATCH 3/6] feat(mcpd): personality routes + chat system block overlay (Stage 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end backend wiring for the agents-feature evolution. After this stage you can curl all the endpoints; CLI + Web UI follow. Routes (new): GET /api/v1/agents/:agentName/personalities POST /api/v1/agents/:agentName/personalities GET /api/v1/personalities/:id PUT /api/v1/personalities/:id DELETE /api/v1/personalities/:id GET /api/v1/personalities/:id/prompts POST /api/v1/personalities/:id/prompts DELETE /api/v1/personalities/:id/prompts/:promptId GET /api/v1/agents/:agentName/prompts (agent-direct) Routes (extended): POST /api/v1/prompts now resolves `agent: ` like `project: ` POST /api/v1/agents/:name/chat accepts `personality: ` RBAC: `personalities` segment maps to the `agents` resource so view/edit/create/delete on the parent agent governs personality access. No new RBAC roles — piggybacking keeps the surface flat. System block (chat.service.ts): agent.systemPrompt + agent-direct prompts (Prompt.agentId === agent.id, priority desc) + project prompts (existing behavior, priority desc) + personality prompts (PersonalityPrompt[chosen], priority desc) + systemAppend Personality is selected by request body `personality: `, falling back to `agent.defaultPersonalityId` if unset. A typo'd flag throws 404 rather than silently dropping back to no overlay — failing loudly on misconfiguration is the only way users learn it didn't apply. Backwards-compatible by construction: when no agent-direct prompts exist and no personality is selected, the resulting block is byte- identical to the old layout (verified by a regression test). Tests: 5 new chat-service.test cases cover ordering, default- personality fallback, missing-personality 404, and the regression guard. mcpd suite: 801/801 (was 796). Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcpd/src/main.ts | 10 +- src/mcpd/src/routes/agent-chat.ts | 3 +- src/mcpd/src/routes/personalities.ts | 154 +++++++++++++++++ src/mcpd/src/routes/prompts.ts | 50 +++++- src/mcpd/src/services/chat.service.ts | 64 ++++++- src/mcpd/src/validation/agent.schema.ts | 6 + src/mcpd/tests/chat-service.test.ts | 216 +++++++++++++++++++++++- 7 files changed, 495 insertions(+), 8 deletions(-) create mode 100644 src/mcpd/src/routes/personalities.ts diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index fe530e2..210a044 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -45,6 +45,8 @@ import { registerAgentChatRoutes } from './routes/agent-chat.js'; import { PromptRepository } from './repositories/prompt.repository.js'; import { PromptRequestRepository } from './repositories/prompt-request.repository.js'; import { PersonalityRepository } from './repositories/personality.repository.js'; +import { PersonalityService } from './services/personality.service.js'; +import { registerPersonalityRoutes } from './routes/personalities.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; import { McpServerService, @@ -163,6 +165,9 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'mcptokens': 'mcptokens', 'llms': 'llms', 'agents': 'agents', + // Personalities inherit the agent's RBAC: managing a personality + // requires view/edit/create/delete on the `agents` resource. + 'personalities': 'agents', }; const resource = resourceMap[segment]; @@ -451,6 +456,7 @@ async function main(): Promise { promptRuleRegistry.register(systemPromptVarsRule); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); const personalityRepo = new PersonalityRepository(prisma); + const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); // ChatService needs the proxy + project repo via the ChatToolDispatcher // bridge. The dispatcher's logger references `app.log`, which is not @@ -579,6 +585,7 @@ async function main(): Promise { registerSecretMigrateRoutes(app, secretMigrateService); registerLlmRoutes(app, llmService); registerAgentRoutes(app, agentService); + registerPersonalityRoutes(app, personalityService); // ChatService needs an `app.log`-aware tool dispatcher. const chatToolDispatcher = new ChatToolDispatcherImpl({ proxy: mcpProxyService, @@ -592,6 +599,7 @@ async function main(): Promise { chatRepo, promptRepo, chatToolDispatcher, + personalityRepo, ); registerAgentChatRoutes(app, chatService); registerLlmInferRoutes(app, { @@ -627,7 +635,7 @@ async function main(): Promise { registerUserRoutes(app, userService); registerGroupRoutes(app, groupService); registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo }); - registerPromptRoutes(app, promptService, projectRepo); + registerPromptRoutes(app, promptService, projectRepo, agentRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/routes/agent-chat.ts b/src/mcpd/src/routes/agent-chat.ts index 0a83579..e332f7a 100644 --- a/src/mcpd/src/routes/agent-chat.ts +++ b/src/mcpd/src/routes/agent-chat.ts @@ -37,7 +37,7 @@ export function registerAgentChatRoutes( } const { - threadId, message, messages: messagesOverride, stream, + threadId, message, messages: messagesOverride, stream, personality, ...paramsRest } = parsed; @@ -49,6 +49,7 @@ export function registerAgentChatRoutes( ...(messagesOverride !== undefined ? { messagesOverride: messagesOverride.map((m) => ({ role: m.role, content: m.content, ...(m.tool_call_id !== undefined ? { tool_call_id: m.tool_call_id } : {}) })) } : {}), + ...(personality !== undefined ? { personalityName: personality } : {}), params: paramsRest, }; diff --git a/src/mcpd/src/routes/personalities.ts b/src/mcpd/src/routes/personalities.ts new file mode 100644 index 0000000..1b818d3 --- /dev/null +++ b/src/mcpd/src/routes/personalities.ts @@ -0,0 +1,154 @@ +/** + * /api/v1/.../personalities — CRUD for agent personalities + prompt bindings. + * + * RBAC inherits from `agents` (see `mapUrlToPermission` in main.ts) — only + * users who can `view/edit/create/delete:agents` can manage that agent's + * personalities. Personalities never escape their agent: there is no + * top-level `/api/v1/personalities` listing. + */ +import type { FastifyInstance } from 'fastify'; +import type { PersonalityService } from '../services/personality.service.js'; +import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; + +export function registerPersonalityRoutes( + app: FastifyInstance, + service: PersonalityService, +): void { + app.get<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/personalities', + async (request, reply) => { + try { + return await service.listForAgent(request.params.agentName); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/personalities', + async (request, reply) => { + try { + const personality = await service.create(request.params.agentName, request.body); + reply.code(201); + return personality; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + if (err instanceof ConflictError) { + reply.code(409); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.get<{ Params: { id: string } }>( + '/api/v1/personalities/:id', + async (request, reply) => { + try { + return await service.getById(request.params.id); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.put<{ Params: { id: string } }>( + '/api/v1/personalities/:id', + async (request, reply) => { + try { + return await service.update(request.params.id, request.body); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { id: string } }>( + '/api/v1/personalities/:id', + async (request, reply) => { + try { + await service.delete(request.params.id); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + // ── Prompt bindings ── + + app.get<{ Params: { id: string } }>( + '/api/v1/personalities/:id/prompts', + async (request, reply) => { + try { + return await service.listBoundPrompts(request.params.id); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.post<{ Params: { id: string } }>( + '/api/v1/personalities/:id/prompts', + async (request, reply) => { + try { + const binding = await service.attachPrompt(request.params.id, request.body); + reply.code(201); + return binding; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + if (err instanceof ConflictError) { + reply.code(409); + return { error: err.message }; + } + throw err; + } + }, + ); + + app.delete<{ Params: { id: string; promptId: string } }>( + '/api/v1/personalities/:id/prompts/:promptId', + async (request, reply) => { + try { + await service.detachPrompt(request.params.id, request.params.promptId); + reply.code(204); + return null; + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); +} diff --git a/src/mcpd/src/routes/prompts.ts b/src/mcpd/src/routes/prompts.ts index 61ddf08..a8b6b85 100644 --- a/src/mcpd/src/routes/prompts.ts +++ b/src/mcpd/src/routes/prompts.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import type { Prompt } from '@prisma/client'; import type { PromptService } from '../services/prompt.service.js'; import type { IProjectRepository, ProjectWithRelations } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; type PromptWithLinkStatus = Prompt & { linkStatus: 'alive' | 'dead' | null }; @@ -56,6 +57,7 @@ export function registerPromptRoutes( app: FastifyInstance, service: PromptService, projectRepo: IProjectRepository, + agentRepo?: IAgentRepository, ): void { // ── Prompts (approved) ── @@ -85,7 +87,31 @@ export function registerPromptRoutes( }); app.post('/api/v1/prompts', async (request, reply) => { - const prompt = await service.createPrompt(request.body); + // Resolve `agent: ` and `project: ` to FK ids before + // handing off to the service. Mirrors the existing project-name + // resolution on `/api/v1/promptrequests`. + const body = request.body as Record; + const resolved: Record = { ...body }; + if (typeof body['project'] === 'string') { + const project = await projectRepo.findByName(body['project']); + if (!project) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + resolved['projectId'] = project.id; + delete resolved['project']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent-scoped prompts not enabled'), { statusCode: 500 }); + } + const agent = await agentRepo.findByName(body['agent']); + if (!agent) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + resolved['agentId'] = agent.id; + delete resolved['agent']; + } + const prompt = await service.createPrompt(resolved); reply.code(201); return prompt; }); @@ -209,4 +235,26 @@ export function registerPromptRoutes( return req; }, ); + + // ── Agent-direct prompts ── + // + // Lists prompts whose `Prompt.agentId` matches the agent. These prompts + // are *not* in any project — they're "always-on" overlays for the agent, + // injected after agent.systemPrompt and before project prompts in the + // chat system block. + app.get<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/prompts', + async (request, reply) => { + if (!agentRepo) { + throw Object.assign(new Error('Agent-scoped prompts not enabled'), { statusCode: 500 }); + } + const agent = await agentRepo.findByName(request.params.agentName); + if (!agent) { + reply.code(404); + return { error: `Agent not found: ${request.params.agentName}` }; + } + const prompts = await service.listPromptsForAgent(agent.id); + return enrichWithLinkStatus(prompts, projectRepo); + }, + ); } diff --git a/src/mcpd/src/services/chat.service.ts b/src/mcpd/src/services/chat.service.ts index 50a5015..fc375b7 100644 --- a/src/mcpd/src/services/chat.service.ts +++ b/src/mcpd/src/services/chat.service.ts @@ -30,6 +30,7 @@ import type { ChatRole, } from '../repositories/chat.repository.js'; import type { IPromptRepository } from '../repositories/prompt.repository.js'; +import type { IPersonalityRepository } from '../repositories/personality.repository.js'; import type { OpenAiChatRequest, OpenAiMessage } from './llm/types.js'; import type { AgentChatParams } from '../validation/agent.schema.js'; import { NotFoundError } from './mcp-server.service.js'; @@ -107,6 +108,13 @@ export interface ChatRequestArgs { messagesOverride?: OpenAiMessage[]; ownerId: string; params?: AgentChatParams; + /** + * Personality overlay for this turn. If set, the personality's bound + * prompts are appended to the system block (additive). If unset, falls + * back to `agent.defaultPersonalityId`. If neither is present, today's + * behavior (no personality overlay) holds. + */ + personalityName?: string; } export interface ChatResult { @@ -123,6 +131,7 @@ export class ChatService { private readonly chatRepo: IChatRepository, private readonly promptRepo: IPromptRepository, private readonly tools: ChatToolDispatcher, + private readonly personalities?: IPersonalityRepository, ) {} async createThread(agentName: string, ownerId: string, title?: string): Promise<{ id: string }> { @@ -361,13 +370,28 @@ export class ChatService { const threadId = await this.resolveThreadId(args, agent.id); const projectId = agent.project?.id ?? null; + // Project prompts (existing): only those whose projectId actually matches + // the agent's project — `findAll(projectId)` also returns globals which + // we exclude here so they don't double-count if a future change adds an + // explicit "global" injection step. const projectPrompts = projectId !== null ? await this.promptRepo.findAll(projectId) : []; - const sortedPrompts = [...projectPrompts] + const sortedProjectPrompts = [...projectPrompts] .filter((p) => p.projectId === projectId) .sort((a, b) => b.priority - a.priority); + // Agent-direct prompts: always-on overlay scoped to this specific agent. + // Ordered after agent.systemPrompt and BEFORE project prompts so + // agent-specific tone/guardrails win over project-wide context. + const agentDirectPrompts = (await this.promptRepo.findByAgent(agent.id)) + .sort((a, b) => b.priority - a.priority); + + // Personality overlay: chooses by request-supplied name first, falling + // back to the agent's defaultPersonalityId. Without a personality this + // path is a no-op and the resulting block matches today's behavior. + const personalityPromptContents = await this.resolvePersonalityPrompts(args, agent); + const mergedParams: AgentChatParams = { ...(agent.defaultParams ?? {}), ...(args.params ?? {}), @@ -376,7 +400,9 @@ export class ChatService { const baseSystem = mergedParams.systemOverride ?? agent.systemPrompt; const systemBlock = [ baseSystem, - ...sortedPrompts.map((p) => p.content), + ...agentDirectPrompts.map((p) => p.content), + ...sortedProjectPrompts.map((p) => p.content), + ...personalityPromptContents, mergedParams.systemAppend ?? '', ] .filter((s) => s.length > 0) @@ -421,6 +447,40 @@ export class ChatService { }; } + /** + * Resolves a personality (request override → agent default) and returns + * its bound prompt contents in `PersonalityPrompt.priority` desc order. + * Returns `[]` when no personality is selected, when the personality + * repository is not wired (legacy callers), or when the named personality + * doesn't exist on this agent. The "doesn't exist" case throws — typos in + * a CLI flag should fail loudly, not silently fall back to no overlay. + */ + private async resolvePersonalityPrompts( + args: ChatRequestArgs, + agent: Awaited>, + ): Promise { + if (this.personalities === undefined) return []; + + let personalityId: string | null = null; + if (args.personalityName !== undefined && args.personalityName !== '') { + const named = await this.personalities.findByNameAndAgent(args.personalityName, agent.id); + if (named === null) { + throw new NotFoundError( + `Personality not found on agent ${agent.name}: ${args.personalityName}`, + ); + } + personalityId = named.id; + } else if (agent.defaultPersonality !== null) { + personalityId = agent.defaultPersonality.id; + } + if (personalityId === null) return []; + + const bindings = await this.personalities.listPrompts(personalityId); + return [...bindings] + .sort((a, b) => b.priority - a.priority) + .map((b) => b.prompt.content); + } + private async resolveThreadId(args: ChatRequestArgs, agentId: string): Promise { if (args.threadId !== undefined) { const existing = await this.chatRepo.findThread(args.threadId); diff --git a/src/mcpd/src/validation/agent.schema.ts b/src/mcpd/src/validation/agent.schema.ts index 5f253c0..2710598 100644 --- a/src/mcpd/src/validation/agent.schema.ts +++ b/src/mcpd/src/validation/agent.schema.ts @@ -114,6 +114,12 @@ export const AgentChatRequestSchema = AgentChatParamsSchema.merge( ) .optional(), stream: z.boolean().optional(), + /** + * Optional personality overlay for this turn. Looked up by name on the + * agent's own personality set (per-agent unique). Falls back to the + * agent's `defaultPersonalityId` when omitted. + */ + personality: z.string().min(1).optional(), }), ).strict().refine((v) => v.message !== undefined || (v.messages?.length ?? 0) > 0, { message: 'Either `message` or `messages` is required', diff --git a/src/mcpd/tests/chat-service.test.ts b/src/mcpd/tests/chat-service.test.ts index c7d8ee8..fe62c3c 100644 --- a/src/mcpd/tests/chat-service.test.ts +++ b/src/mcpd/tests/chat-service.test.ts @@ -6,7 +6,8 @@ import type { LlmAdapterRegistry } from '../src/services/llm/dispatcher.js'; import type { LlmAdapter, NonStreamingResult, InferContext } from '../src/services/llm/types.js'; import type { IChatRepository } from '../src/repositories/chat.repository.js'; import type { IPromptRepository } from '../src/repositories/prompt.repository.js'; -import type { ChatMessage, ChatThread, Prompt } from '@prisma/client'; +import type { IPersonalityRepository } from '../src/repositories/personality.repository.js'; +import type { ChatMessage, ChatThread, Prompt, Personality, PersonalityPrompt } from '@prisma/client'; const NOW = new Date(); @@ -76,9 +77,11 @@ function mockChatRepo(): IChatRepository & { _msgs: ChatMessage[]; _threads: Cha function mockPromptRepo(rows: Prompt[] = []): IPromptRepository { return { findAll: vi.fn(async () => rows), - findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null)), + findGlobal: vi.fn(async () => rows.filter((p) => p.projectId === null && p.agentId === null)), + findByAgent: vi.fn(async (agentId: string) => rows.filter((p) => p.agentId === agentId)), findById: vi.fn(async (id: string) => rows.find((p) => p.id === id) ?? null), findByNameAndProject: vi.fn(async () => null), + findByNameAndAgent: vi.fn(async () => null), create: vi.fn(), update: vi.fn(), delete: vi.fn(), @@ -92,7 +95,7 @@ function mockTools(impl: Partial = {}): ChatToolDispatcher { }; } -function mockAgents(): AgentService { +function mockAgents(opts: { defaultPersonality?: { id: string; name: string } | null } = {}): AgentService { return { getByName: vi.fn(async (name: string) => ({ id: `agent-${name}`, @@ -103,6 +106,7 @@ function mockAgents(): AgentService { project: name === 'no-project' ? null : { id: 'proj-1', name: 'mcpctl-dev' }, + defaultPersonality: opts.defaultPersonality ?? null, proxyModelName: null, defaultParams: { temperature: 0.5 }, extras: {}, @@ -567,4 +571,210 @@ describe('ChatService', () => { await expect(svc.listMessages('cnonexistent000000000000000', 'alice')) .rejects.toThrow(/not found/i); }); + + // ── Agent-direct prompts + personality overlay (Stage 3 system block) ── + + it('injects agent-direct prompts BETWEEN agent.systemPrompt and project prompts', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const prompts: Prompt[] = [ + // Project prompt + { + id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT', + projectId: 'proj-1', agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }, + // Agent-direct prompt + { + id: 'p-direct', name: 'direct', content: 'AGENT_DIRECT_TEXT', + projectId: null, agentId: 'agent-reviewer', priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }, + ]; + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo(prompts), mockTools(), + ); + await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + const text = sys?.content as string; + expect(text.indexOf('You are a helpful agent.')).toBeLessThan(text.indexOf('AGENT_DIRECT_TEXT')); + expect(text.indexOf('AGENT_DIRECT_TEXT')).toBeLessThan(text.indexOf('PROJECT_TEXT')); + }); + + it('appends personality-bound prompts after project prompts when --personality is passed', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const projectPrompt: Prompt = { + id: 'p-proj', name: 'proj', content: 'PROJECT_TEXT', + projectId: 'proj-1', agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + const personalityPrompt: Prompt = { + id: 'p-pers', name: 'pers', content: 'PERSONALITY_TEXT', + projectId: null, agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + + const personalities = mockPersonalityRepo({ + 'pers-grumpy': { + personality: makePersonality({ id: 'pers-grumpy', name: 'grumpy', agentId: 'agent-reviewer' }), + bindings: [{ promptId: personalityPrompt.id, priority: 5 }], + }, + }, [projectPrompt, personalityPrompt]); + + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo([projectPrompt, personalityPrompt]), mockTools(), + personalities, + ); + await svc.chat({ + agentName: 'reviewer', + userMessage: 'hi', + ownerId: 'owner-1', + personalityName: 'grumpy', + }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + const text = sys?.content as string; + expect(text.indexOf('PROJECT_TEXT')).toBeLessThan(text.indexOf('PERSONALITY_TEXT')); + }); + + it('falls back to agent.defaultPersonality when --personality is omitted', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const personalityPrompt: Prompt = { + id: 'p-pers', name: 'pers', content: 'DEFAULT_PERSONALITY_TEXT', + projectId: null, agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + const personalities = mockPersonalityRepo({ + 'pers-default': { + personality: makePersonality({ id: 'pers-default', name: 'default', agentId: 'agent-reviewer' }), + bindings: [{ promptId: personalityPrompt.id, priority: 5 }], + }, + }, [personalityPrompt]); + + const svc = new ChatService( + mockAgents({ defaultPersonality: { id: 'pers-default', name: 'default' } }), + mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo([personalityPrompt]), mockTools(), + personalities, + ); + await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + expect(sys?.content as string).toContain('DEFAULT_PERSONALITY_TEXT'); + }); + + it('throws when --personality references a name the agent does not own', async () => { + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const personalities = mockPersonalityRepo({}); + + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo(), mockTools(), + personalities, + ); + await expect(svc.chat({ + agentName: 'reviewer', + userMessage: 'hi', + ownerId: 'owner-1', + personalityName: 'ghost', + })).rejects.toThrow(/Personality not found/); + }); + + it('preserves today\'s system block when no personality and no agent-direct prompts exist', async () => { + // Regression guard: backwards-compatible by construction. + const chatRepo = mockChatRepo(); + const adapter = scriptedAdapter([chatCompletion('ok')]); + const inferSpy = adapter.infer as ReturnType; + const projectPrompt: Prompt = { + id: 'p-proj', name: 'proj', content: 'ONLY_PROJECT_TEXT', + projectId: 'proj-1', agentId: null, priority: 5, summary: null, + chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + }; + const svc = new ChatService( + mockAgents(), mockLlms(), adapterRegistry(adapter), + chatRepo, mockPromptRepo([projectPrompt]), mockTools(), + ); + await svc.chat({ agentName: 'reviewer', userMessage: 'hi', ownerId: 'owner-1' }); + const sys = (inferSpy.mock.calls[0][0] as InferContext).body.messages.find((m) => m.role === 'system'); + const text = sys?.content as string; + expect(text).toContain('You are a helpful agent.'); + expect(text).toContain('ONLY_PROJECT_TEXT'); + }); }); + +// ── Helpers for personality-overlay tests ── + +function makePersonality(overrides: Partial = {}): Personality { + return { + id: `pers-${Math.random().toString(36).slice(2, 8)}`, + name: 'p', + description: '', + agentId: 'agent-reviewer', + priority: 5, + createdAt: NOW, + updatedAt: NOW, + ...overrides, + }; +} + +interface MockPersonalityFixture { + personality: Personality; + bindings: Array<{ promptId: string; priority: number }>; +} + +function mockPersonalityRepo( + fixtures: Record, + prompts: Prompt[] = [], +): IPersonalityRepository { + const byId = new Map(Object.entries(fixtures)); + const promptsById = new Map(prompts.map((p) => [p.id, p])); + return { + findAll: vi.fn(async () => [...byId.values()].map((f) => f.personality)), + findByAgent: vi.fn(async (agentId: string) => + [...byId.values()].filter((f) => f.personality.agentId === agentId).map((f) => f.personality)), + findById: vi.fn(async (id: string) => byId.get(id)?.personality ?? null), + findByNameAndAgent: vi.fn(async (name: string, agentId: string) => { + for (const f of byId.values()) { + if (f.personality.name === name && f.personality.agentId === agentId) { + return f.personality; + } + } + return null; + }), + create: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + listPrompts: vi.fn(async (personalityId: string) => { + const fixture = byId.get(personalityId); + if (!fixture) return []; + return fixture.bindings.map((b) => ({ + id: `bind-${b.promptId}`, + personalityId, + promptId: b.promptId, + priority: b.priority, + createdAt: NOW, + prompt: promptsById.get(b.promptId) ?? ({ + id: b.promptId, name: 'p', content: '', + projectId: null, agentId: null, priority: b.priority, + summary: null, chapters: null, linkTarget: null, version: 1, + createdAt: NOW, updatedAt: NOW, + } as Prompt), + })); + }), + attachPrompt: vi.fn(), + detachPrompt: vi.fn(), + findBinding: vi.fn(async () => null), + }; +} -- 2.49.1 From 9050918a838d7f6df863de408f7a39f4fccbd068 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:32:48 +0100 Subject: [PATCH 4/6] feat(cli): personality flag + create/get/edit/delete personalities (Stage 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end CLI surface for the personality overlay: mcpctl create personality grumpy --agent reviewer --description "be terse" mcpctl create prompt tone --agent reviewer --content "Be very terse." mcpctl get personalities mcpctl get personalities --agent reviewer mcpctl edit personality mcpctl delete personality grumpy --agent reviewer mcpctl chat reviewer --personality grumpy Chat banner gains a "Personality:" line that shows either the active flag value or the agent's `defaultPersonality` (when no flag given), so the user knows which overlay is in effect before sending a message. `--personality` is stripped from `/save` (it's a per-turn override, not a `defaultParams` field — the agent's defaultPersonality lives on its own column and is set via PUT /agents). Backend (small additions to land Stage 4 cleanly): - `GET /api/v1/personalities[?agent=name]` so `mcpctl get personalities` doesn't require an agent filter. - PersonalityService.listAll() aggregates across agents. Completions: regenerated fish + bash. `personalities` added as a canonical resource with `personality` alias; edit-resource list extended; the per-resource argument completers pick up the new type automatically. CLI suite: 430/430. mcpd: 801/801. Typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 21 +++++----- completions/mcpctl.fish | 28 +++++++++----- scripts/generate-completions.ts | 5 ++- src/cli/src/commands/chat.ts | 20 +++++++++- src/cli/src/commands/create.ts | 40 +++++++++++++++++++- src/cli/src/commands/delete.ts | 27 ++++++++++++- src/cli/src/commands/edit.ts | 2 +- src/cli/src/commands/get.ts | 22 +++++++++++ src/cli/src/commands/shared.ts | 2 + src/mcpd/src/routes/personalities.ts | 18 +++++++++ src/mcpd/src/services/personality.service.ts | 12 ++++++ 11 files changed, 171 insertions(+), 26 deletions(-) diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index ed1b7e1..d0ea0a4 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -8,8 +8,8 @@ _mcpctl() { local commands="status login logout config get describe delete logs create edit apply chat patch backup approve console cache test migrate rotate" local project_commands="get describe delete logs create edit attach-server detach-server" local global_opts="-v --version --daemon-url --direct -p --project -h --help" - local resources="servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" - local resource_aliases="servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" + local resources="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels all" + local resource_aliases="servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm" # Check if --project/-p was given local has_project=false @@ -156,11 +156,11 @@ _mcpctl() { return ;; delete) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "$resources -p --project -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$resources -p --project --agent -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") - COMPREPLY=($(compgen -W "$names -p --project -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names -p --project --agent -h --help" -- "$cur")) fi return ;; logs) @@ -175,7 +175,7 @@ _mcpctl() { create) local create_sub=$(_mcpctl_get_subcmd $subcmd_pos) if [[ -z "$create_sub" ]]; then - COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt personality serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -209,7 +209,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-p --project --rbac --bind --ttl --description --force -h --help" -- "$cur")) ;; prompt) - COMPREPLY=($(compgen -W "-p --project --content --content-file --priority --link -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project --agent --content --content-file --priority --link -h --help" -- "$cur")) + ;; + personality) + COMPREPLY=($(compgen -W "--agent --description --priority -h --help" -- "$cur")) ;; serverattachment) COMPREPLY=($(compgen -W "-p --project -h --help" -- "$cur")) @@ -225,7 +228,7 @@ _mcpctl() { return ;; edit) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") @@ -239,9 +242,9 @@ _mcpctl() { if [[ $((cword - subcmd_pos)) -eq 1 ]]; then local names names=$(_mcpctl_resource_names "agents") - COMPREPLY=($(compgen -W "$names -m --message --thread --system --system-file --system-append --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names -m --message --thread --system --system-file --system-append --personality --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) else - COMPREPLY=($(compgen -W "-m --message --thread --system --system-file --system-append --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-m --message --thread --system --system-file --system-append --personality --temperature --top-p --top-k --max-tokens --seed --stop --allow-tool --extra --no-stream -h --help" -- "$cur")) fi return ;; patch) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index e02363f..ed739a7 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -31,10 +31,10 @@ function __mcpctl_has_project end # Resource type detection -set -l resources servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all +set -l resources servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels all function __mcpctl_needs_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -62,6 +62,7 @@ function __mcpctl_resolve_resource case secretbackend sb secretbackends; echo secretbackends case llm llms; echo llms case agent agents; echo agents + case personality personalities; echo personalities case template tpl templates; echo templates case project proj projects; echo projects case user users; echo users @@ -77,7 +78,7 @@ function __mcpctl_resolve_resource end function __mcpctl_get_resource_type - set -l resource_aliases servers instances secrets secretbackends llms agents templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm + set -l resource_aliases servers instances secrets secretbackends llms agents personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels all server srv instance inst secret sec secretbackend sb llm agent personality template tpl project proj user group rbac-definition rbac-binding prompt promptrequest pr serverattachment sa proxymodel pm set -l tokens (commandline -opc) set -l found_cmd false for tok in $tokens @@ -224,7 +225,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a config -d 'Manage mcpctl configuration' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a get -d 'List resources (servers, projects, instances, all)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a describe -d 'Show detailed information about a resource' -complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac, personality)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a logs -d 'Get logs from an MCP server instance' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a edit -d 'Edit a resource in your default editor (server, project)' @@ -242,7 +243,7 @@ complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_ # Project-scoped commands (with --project) complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a get -d 'List resources (servers, projects, instances, all)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a describe -d 'Show detailed information about a resource' -complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac)' +complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a delete -d 'Delete a resource (server, instance, secret, project, user, group, rbac, personality)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a logs -d 'Get logs from an MCP server instance' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a create -d 'Create a resource (server, secret, secretbackend, llm, agent, project, user, group, rbac, serverattachment, prompt)' complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from $project_commands" -a edit -d 'Edit a resource in your default editor (server, project)' @@ -251,7 +252,7 @@ complete -c mcpctl -n "__mcpctl_has_project; and not __fish_seen_subcommand_from # Resource types — only when resource type not yet selected complete -c mcpctl -n "__fish_seen_subcommand_from get describe delete patch; and __mcpctl_needs_resource_type" -a "$resources" -d 'Resource type' -complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers secrets projects groups rbac prompts promptrequests' -d 'Resource type' +complete -c mcpctl -n "__fish_seen_subcommand_from edit; and __mcpctl_needs_resource_type" -a 'servers secrets projects groups rbac prompts promptrequests personalities' -d 'Resource type' complete -c mcpctl -n "__fish_seen_subcommand_from approve; and __mcpctl_needs_resource_type" -a 'promptrequest' -d 'Resource type' # Resource names — after resource type is selected @@ -287,7 +288,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' # create subcommands -set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt serverattachment promptrequest +set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt personality serverattachment promptrequest complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a server -d 'Create an MCP server definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a secret -d 'Create a secret' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a llm -d 'Register a server-managed LLM (anthropic, openai, vllm, ollama, deepseek, gemini-cli)' @@ -298,7 +299,8 @@ complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a group -d 'Create a group' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a rbac -d 'Create an RBAC binding definition' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a mcptoken -d 'Create a project-scoped API token for HTTP-mode mcplocal. The raw token is printed once.' -complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a prompt -d 'Create an approved prompt (scope: project, agent, or global)' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a personality -d 'Create a personality overlay on an agent' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a serverattachment -d 'Attach a server to a project' complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a promptrequest -d 'Create a prompt request (pending proposal that needs approval)' @@ -406,12 +408,18 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l description -d complete -c mcpctl -n "__mcpctl_subcmd_active create mcptoken" -l force -d 'Revoke any existing active token with this name, then create a new one' # create prompt options -complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -s p -l project -d 'Project name to scope the prompt to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -s p -l project -d 'Project to scope the prompt to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l agent -d 'Agent to attach the prompt to directly (XOR with --project)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content -d 'Prompt content text' -x complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content-file -d 'Read prompt content from file' -rF complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l priority -d 'Priority 1-10 (default: 5, higher = more important)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l link -d 'Link to MCP resource (format: project/server:uri)' -x +# create personality options +complete -c mcpctl -n "__mcpctl_subcmd_active create personality" -l agent -d 'Agent that owns this personality (required)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create personality" -l description -d 'Description shown in `mcpctl get personalities`' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create personality" -l priority -d 'Priority 1-10 (default: 5)' -x + # create serverattachment options complete -c mcpctl -n "__mcpctl_subcmd_active create serverattachment" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' @@ -483,6 +491,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from describe" -l show-values -d ' # delete options complete -c mcpctl -n "__fish_seen_subcommand_from delete" -s p -l project -d 'Project name (for serverattachment)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__fish_seen_subcommand_from delete" -l agent -d 'Agent name (for personality delete-by-name)' -x # logs options complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s t -l tail -d 'Number of lines to show' -x @@ -498,6 +507,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l thread -d 'Resume an complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system -d 'Replace agent.systemPrompt for this session' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-file -d 'Read --system text from a file' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l system-append -d 'Append to the agent system block for this session' -x +complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l personality -d 'Personality overlay (additive prompts on top of the agent)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l temperature -d 'Sampling temperature (0..2)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l top-p -d 'Nucleus sampling cutoff (0..1)' -x complete -c mcpctl -n "__fish_seen_subcommand_from chat" -l top-k -d 'Top-K sampling (Anthropic; OpenAI ignores)' -x diff --git a/scripts/generate-completions.ts b/scripts/generate-completions.ts index 761b0bd..a0941a1 100644 --- a/scripts/generate-completions.ts +++ b/scripts/generate-completions.ts @@ -44,7 +44,7 @@ const COMPLETION_HINTS: Record = { 'describe.id': 'resource_names', 'delete.resource': 'resource_types', 'delete.id': 'resource_names', - 'edit.resource': { static: ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests'] }, + 'edit.resource': { static: ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities'] }, 'edit.name-or-id': 'resource_names', 'patch.resource': 'resource_types', 'patch.name': 'resource_names', @@ -184,7 +184,7 @@ async function extractTree(): Promise { // ============================================================ const CANONICAL_RESOURCES = [ - 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', 'templates', 'projects', + 'servers', 'instances', 'secrets', 'secretbackends', 'llms', 'agents', 'personalities', 'templates', 'projects', 'users', 'groups', 'rbac', 'prompts', 'promptrequests', 'serverattachments', 'proxymodels', 'all', ]; @@ -196,6 +196,7 @@ const ALIAS_ENTRIES: [string, string][] = [ ['secretbackend', 'secretbackends'], ['sb', 'secretbackends'], ['llm', 'llms'], ['llms', 'llms'], ['agent', 'agents'], ['agents', 'agents'], + ['personality', 'personalities'], ['personalities', 'personalities'], ['template', 'templates'], ['tpl', 'templates'], ['project', 'projects'], ['proj', 'projects'], ['user', 'users'], diff --git a/src/cli/src/commands/chat.ts b/src/cli/src/commands/chat.ts index 6b3a184..e302cb6 100644 --- a/src/cli/src/commands/chat.ts +++ b/src/cli/src/commands/chat.ts @@ -45,6 +45,7 @@ export function createChatCommand(deps: ChatCommandDeps): Command { .option('--system ', 'Replace agent.systemPrompt for this session') .option('--system-file ', 'Read --system text from a file') .option('--system-append ', 'Append to the agent system block for this session') + .option('--personality ', 'Personality overlay (additive prompts on top of the agent)') .option('--temperature ', 'Sampling temperature (0..2)', parseFloat) .option('--top-p ', 'Nucleus sampling cutoff (0..1)', parseFloat) .option('--top-k ', 'Top-K sampling (Anthropic; OpenAI ignores)', parseFloatInt) @@ -71,6 +72,7 @@ interface ChatOpts { system?: string; systemFile?: string; systemAppend?: string; + personality?: string; temperature?: number; topP?: number; topK?: number; @@ -85,6 +87,7 @@ interface ChatOpts { interface Overrides { systemOverride?: string; systemAppend?: string; + personality?: string; temperature?: number; top_p?: number; top_k?: number; @@ -103,6 +106,7 @@ async function buildInitialOverrides(opts: ChatOpts): Promise { } if (system !== undefined) out.systemOverride = system; if (opts.systemAppend !== undefined) out.systemAppend = opts.systemAppend; + if (opts.personality !== undefined) out.personality = opts.personality; if (opts.temperature !== undefined) out.temperature = opts.temperature; if (opts.topP !== undefined) out.top_p = opts.topP; if (opts.topK !== undefined) out.top_k = opts.topK; @@ -298,10 +302,14 @@ async function handleSlash( } function stripSession(o: Overrides): Record { - // /save persists sampling defaults but not the per-session systemOverride / systemAppend. + // /save persists sampling defaults but not per-session persona controls + // (--system / --system-append) or the per-turn --personality overlay. + // The agent's defaultPersonality is set via PATCH /agents (or `mcpctl edit + // agent`) — it is NOT a sampling param. const out: Record = { ...o }; delete out.systemOverride; delete out.systemAppend; + delete out.personality; return out; } @@ -669,6 +677,7 @@ interface AgentInfo { systemPrompt: string; llm: { name: string }; project: { name: string } | null; + defaultPersonality?: { name: string } | null; } /** @@ -702,6 +711,15 @@ async function printChatHeader( const tail = info.project !== null ? ` Project: ${info.project.name}` : ''; out(`LLM: ${info.llm.name}${tail}`); + // Personality overlay: explicit --personality wins; otherwise agent's + // defaultPersonality (if set). Tells the user which prompt bundle is + // active before they type anything. + if (overrides.personality !== undefined) { + out(`Personality: ${overrides.personality} (--personality)`); + } else if (info.defaultPersonality) { + out(`Personality: ${info.defaultPersonality.name} (agent default)`); + } + if (overrides.systemOverride !== undefined) { out(`System prompt (--system replaces agent.systemPrompt):`); out(indent(overrides.systemOverride)); diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index 5e5122a..a94f677 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -727,14 +727,18 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { // --- create prompt --- cmd.command('prompt') - .description('Create an approved prompt') + .description('Create an approved prompt (scope: project, agent, or global)') .argument('', 'Prompt name (lowercase alphanumeric with hyphens)') - .option('-p, --project ', 'Project name to scope the prompt to') + .option('-p, --project ', 'Project to scope the prompt to') + .option('--agent ', 'Agent to attach the prompt to directly (XOR with --project)') .option('--content ', 'Prompt content text') .option('--content-file ', 'Read prompt content from file') .option('--priority ', 'Priority 1-10 (default: 5, higher = more important)') .option('--link ', 'Link to MCP resource (format: project/server:uri)') .action(async (name: string, opts) => { + if (opts.project && opts.agent) { + throw new Error('--project and --agent are mutually exclusive'); + } let content = opts.content as string | undefined; if (opts.contentFile) { const fs = await import('node:fs/promises'); @@ -756,6 +760,10 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (!project) throw new Error(`Project '${opts.project as string}' not found`); body.projectId = project.id; } + if (opts.agent) { + // Send agent name; mcpd resolves it server-side. + body.agent = opts.agent; + } if (opts.priority) { const priority = Number(opts.priority); if (isNaN(priority) || priority < 1 || priority > 10) { @@ -771,6 +779,34 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { log(`prompt '${prompt.name}' created (id: ${prompt.id})`); }); + // --- create personality --- + cmd.command('personality') + .description('Create a personality overlay on an agent') + .argument('', 'Personality name (lowercase alphanumeric with hyphens)') + .option('--agent ', 'Agent that owns this personality (required)') + .option('--description ', 'Description shown in `mcpctl get personalities`') + .option('--priority ', 'Priority 1-10 (default: 5)') + .action(async (name: string, opts) => { + const agentName = opts.agent as string | undefined; + if (!agentName) { + throw new Error('--agent is required'); + } + const body: Record = { name }; + if (opts.description) body.description = opts.description; + if (opts.priority) { + const priority = Number(opts.priority); + if (isNaN(priority) || priority < 1 || priority > 10) { + throw new Error('--priority must be a number between 1 and 10'); + } + body.priority = priority; + } + const personality = await client.post<{ id: string; name: string }>( + `/api/v1/agents/${encodeURIComponent(agentName)}/personalities`, + body, + ); + log(`personality '${personality.name}' created on agent '${agentName}' (id: ${personality.id})`); + }); + // --- create serverattachment --- cmd.command('serverattachment') .alias('sa') diff --git a/src/cli/src/commands/delete.ts b/src/cli/src/commands/delete.ts index cd5af79..7814870 100644 --- a/src/cli/src/commands/delete.ts +++ b/src/cli/src/commands/delete.ts @@ -11,11 +11,12 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command { const { client, log } = deps; return new Command('delete') - .description('Delete a resource (server, instance, secret, project, user, group, rbac)') + .description('Delete a resource (server, instance, secret, project, user, group, rbac, personality)') .argument('', 'resource type') .argument('', 'resource ID or name') .option('-p, --project ', 'Project name (for serverattachment)') - .action(async (resourceArg: string, idOrName: string, opts: { project?: string }) => { + .option('--agent ', 'Agent name (for personality delete-by-name)') + .action(async (resourceArg: string, idOrName: string, opts: { project?: string; agent?: string }) => { const resource = resolveResource(resourceArg); // Serverattachments: delete serverattachment --project @@ -29,6 +30,28 @@ export function createDeleteCommand(deps: DeleteCommandDeps): Command { return; } + // Personalities: names are unique per-agent, so by-name delete requires --agent + // (or pass a CUID directly). + if (resource === 'personalities') { + let personalityId: string; + if (/^c[a-z0-9]{24}/.test(idOrName)) { + personalityId = idOrName; + } else { + if (!opts.agent) { + throw new Error('--agent is required to delete a personality by name (or pass the id).'); + } + const items = await client.get>( + `/api/v1/agents/${encodeURIComponent(opts.agent)}/personalities`, + ); + const match = items.find((i) => i.name === idOrName); + if (!match) throw new Error(`personality '${idOrName}' not found on agent '${opts.agent}'`); + personalityId = match.id; + } + await client.delete(`/api/v1/personalities/${personalityId}`); + log(`personality '${idOrName}' deleted.`); + return; + } + // Mcptokens: names are scoped to a project, so require --project unless the caller passes a CUID if (resource === 'mcptokens') { let tokenId: string; diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index 3d48c25..dbfcb41 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -48,7 +48,7 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } - const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests']; + const validResources = ['servers', 'secrets', 'projects', 'groups', 'rbac', 'prompts', 'promptrequests', 'personalities']; if (!validResources.includes(resource)) { log(`Error: unknown resource type '${resourceArg}'`); process.exitCode = 1; diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 82fd128..b56ec8d 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -159,6 +159,25 @@ const agentColumns: Column[] = [ { header: 'ID', key: 'id' }, ]; +interface PersonalityRow { + id: string; + name: string; + description: string; + agentId: string; + agentName: string; + priority: number; + promptCount: number; +} + +const personalityColumns: Column[] = [ + { header: 'NAME', key: 'name' }, + { header: 'AGENT', key: 'agentName', width: 24 }, + { header: 'PROMPTS', key: (r) => String(r.promptCount), width: 8 }, + { header: 'PRIORITY', key: (r) => String(r.priority), width: 8 }, + { header: 'DESCRIPTION', key: (r) => truncate(r.description, 40) || '-', width: 40 }, + { header: 'ID', key: 'id' }, +]; + function truncate(s: string, max: number): string { if (s.length <= max) return s; return s.slice(0, max - 1) + '…'; @@ -345,6 +364,8 @@ function getColumnsForResource(resource: string): Column return llmColumns as unknown as Column>[]; case 'agents': return agentColumns as unknown as Column>[]; + case 'personalities': + return personalityColumns as unknown as Column>[]; default: return [ { header: 'ID', key: 'id' as keyof Record }, @@ -370,6 +391,7 @@ const RESOURCE_KIND: Record = { secretbackends: 'secretbackend', llms: 'llm', agents: 'agent', + personalities: 'personality', }; /** diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index fcfcafa..ec44e67 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -38,6 +38,8 @@ export const RESOURCE_ALIASES: Record = { llms: 'llms', agent: 'agents', agents: 'agents', + personality: 'personalities', + personalities: 'personalities', thread: 'threads', threads: 'threads', all: 'all', diff --git a/src/mcpd/src/routes/personalities.ts b/src/mcpd/src/routes/personalities.ts index 1b818d3..8461c67 100644 --- a/src/mcpd/src/routes/personalities.ts +++ b/src/mcpd/src/routes/personalities.ts @@ -14,6 +14,24 @@ export function registerPersonalityRoutes( app: FastifyInstance, service: PersonalityService, ): void { + app.get<{ Querystring: { agent?: string } }>( + '/api/v1/personalities', + async (request, reply) => { + try { + if (request.query.agent !== undefined) { + return await service.listForAgent(request.query.agent); + } + return await service.listAll(); + } catch (err) { + if (err instanceof NotFoundError) { + reply.code(404); + return { error: err.message }; + } + throw err; + } + }, + ); + app.get<{ Params: { agentName: string } }>( '/api/v1/agents/:agentName/personalities', async (request, reply) => { diff --git a/src/mcpd/src/services/personality.service.ts b/src/mcpd/src/services/personality.service.ts index d6812cf..4386b5c 100644 --- a/src/mcpd/src/services/personality.service.ts +++ b/src/mcpd/src/services/personality.service.ts @@ -54,6 +54,18 @@ export class PersonalityService { private readonly promptRepo: IPromptRepository, ) {} + async listAll(): Promise { + const rows = await this.repo.findAll(); + const agents = new Map(); + for (const r of rows) { + if (!agents.has(r.agentId)) { + const agent = await this.agentRepo.findById(r.agentId); + agents.set(r.agentId, agent?.name ?? r.agentId); + } + } + return Promise.all(rows.map((r) => this.toView(r, agents.get(r.agentId) ?? r.agentId))); + } + async listForAgent(agentName: string): Promise { const agent = await this.agentRepo.findByName(agentName); if (agent === null) throw new NotFoundError(`Agent not found: ${agentName}`); -- 2.49.1 From 0010cc18b7f94b14aafba8e1d8651a1cdd8bbd73 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:41:57 +0100 Subject: [PATCH 5/6] feat(web): browser-based prompt + personality editor (Stage 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- pnpm-lock.yaml | 986 +++++++++++++++++++++++- src/web/.gitignore | 4 + src/web/index.html | 21 + src/web/package.json | 28 + src/web/src/App.tsx | 42 + src/web/src/api.ts | 103 +++ src/web/src/components/Layout.tsx | 80 ++ src/web/src/components/Login.tsx | 120 +++ src/web/src/components/PromptEditor.tsx | 155 ++++ src/web/src/hooks/useFetch.ts | 35 + src/web/src/main.tsx | 11 + src/web/src/pages/AgentDetail.tsx | 317 ++++++++ src/web/src/pages/Agents.tsx | 40 + src/web/src/pages/PersonalityDetail.tsx | 224 ++++++ src/web/src/pages/ProjectPrompts.tsx | 177 +++++ src/web/src/pages/Projects.tsx | 35 + src/web/tests/api.test.ts | 62 ++ src/web/tests/login.test.tsx | 37 + src/web/tests/setup.ts | 1 + src/web/tsconfig.json | 25 + src/web/vite.config.ts | 41 + 21 files changed, 2539 insertions(+), 5 deletions(-) create mode 100644 src/web/.gitignore create mode 100644 src/web/index.html create mode 100644 src/web/package.json create mode 100644 src/web/src/App.tsx create mode 100644 src/web/src/api.ts create mode 100644 src/web/src/components/Layout.tsx create mode 100644 src/web/src/components/Login.tsx create mode 100644 src/web/src/components/PromptEditor.tsx create mode 100644 src/web/src/hooks/useFetch.ts create mode 100644 src/web/src/main.tsx create mode 100644 src/web/src/pages/AgentDetail.tsx create mode 100644 src/web/src/pages/Agents.tsx create mode 100644 src/web/src/pages/PersonalityDetail.tsx create mode 100644 src/web/src/pages/ProjectPrompts.tsx create mode 100644 src/web/src/pages/Projects.tsx create mode 100644 src/web/tests/api.test.ts create mode 100644 src/web/tests/login.test.tsx create mode 100644 src/web/tests/setup.ts create mode 100644 src/web/tsconfig.json create mode 100644 src/web/vite.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ade254b..e64646d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + version: 4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2)) eslint: specifier: ^10.0.1 version: 10.0.1(jiti@2.6.1) @@ -37,7 +37,7 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.18 - version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + version: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2) src/cli: dependencies: @@ -181,12 +181,107 @@ importers: specifier: ^3.24.0 version: 3.25.76 + src/web: + dependencies: + '@monaco-editor/react': + specifier: ^4.7.0 + version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react: + specifier: ^19.2.5 + version: 19.2.5 + react-dom: + specifier: ^19.2.5 + version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.7.0 + version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + devDependencies: + '@testing-library/jest-dom': + specifier: ^6.7.0 + version: 6.9.1 + '@testing-library/react': + specifier: ^16.3.0 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@types/react': + specifier: ^19.2.14 + version: 19.2.14 + '@types/react-dom': + specifier: ^19.2.0 + version: 19.2.3(@types/react@19.2.14) + '@vitejs/plugin-react': + specifier: ^5.1.0 + version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) + jsdom: + specifier: ^28.0.0 + version: 28.1.0 + vite: + specifier: ^7.2.0 + version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + packages: + '@acemir/cssom@0.9.31': + resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==} + + '@adobe/css-tools@4.4.4': + resolution: {integrity: sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==} + '@alcalzone/ansi-tokenize@0.2.5': resolution: {integrity: sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw==} engines: {node: '>=18'} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@6.8.1': + resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + + '@babel/code-frame@7.29.0': + resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.0': + resolution: {integrity: sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.0': + resolution: {integrity: sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.1': + resolution: {integrity: sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.28.6': + resolution: {integrity: sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.28.6': + resolution: {integrity: sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.6': + resolution: {integrity: sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.28.6': + resolution: {integrity: sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==} + engines: {node: '>=6.9.0'} + '@babel/helper-string-parser@7.27.1': resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} engines: {node: '>=6.9.0'} @@ -195,11 +290,43 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.2': + resolution: {integrity: sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.0': resolution: {integrity: sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==} engines: {node: '>=6.0.0'} hasBin: true + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + + '@babel/template@7.28.6': + resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.0': + resolution: {integrity: sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==} + engines: {node: '>=6.9.0'} + '@babel/types@7.29.0': resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} engines: {node: '>=6.9.0'} @@ -211,6 +338,46 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.0.2': + resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.2.0': + resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.0': + resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3': + resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -397,6 +564,15 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.0': + resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -600,6 +776,12 @@ packages: '@types/node': optional: true + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@jridgewell/resolve-uri@3.1.2': resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} @@ -646,6 +828,16 @@ packages: '@cfworker/json-schema': optional: true + '@monaco-editor/loader@1.7.0': + resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} + + '@monaco-editor/react@4.7.0': + resolution: {integrity: sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==} + peerDependencies: + monaco-editor: '>= 0.25.0 < 1' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -709,6 +901,9 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@rolldown/pluginutils@1.0.0-rc.3': + resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==} + '@rollup/rollup-android-arm-eabi@4.58.0': resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==} cpu: [arm] @@ -837,6 +1032,44 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/jest-dom@6.9.1': + resolution: {integrity: sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + '@types/bcrypt@5.0.2': resolution: {integrity: sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==} @@ -880,6 +1113,11 @@ packages: '@types/node@25.3.0': resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -889,6 +1127,9 @@ packages: '@types/stream-buffers@3.0.8': resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==} + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@typescript-eslint/eslint-plugin@8.56.0': resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -948,6 +1189,12 @@ packages: resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vitejs/plugin-react@5.2.0': + resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + '@vitest/coverage-v8@4.0.18': resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} peerDependencies: @@ -1044,6 +1291,10 @@ packages: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -1059,6 +1310,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + asn1@0.2.6: resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} @@ -1142,6 +1400,11 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.23: + resolution: {integrity: sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==} + engines: {node: '>=6.0.0'} + hasBin: true + bcrypt-pbkdf@1.0.2: resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} @@ -1149,6 +1412,9 @@ packages: resolution: {integrity: sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==} engines: {node: '>= 10.0.0'} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} @@ -1166,6 +1432,11 @@ packages: resolution: {integrity: sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==} engines: {node: 20 || >=22} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} @@ -1193,6 +1464,9 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + caniuse-lite@1.0.30001791: + resolution: {integrity: sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -1289,6 +1563,9 @@ packages: resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} engines: {node: '>= 0.6'} + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + convert-to-spaces@2.0.1: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1317,9 +1594,24 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + + cssstyle@6.2.0: + resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==} + engines: {node: '>=20'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + debug@4.4.3: resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} engines: {node: '>=6.0'} @@ -1329,6 +1621,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1377,6 +1672,15 @@ packages: resolution: {integrity: sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q==} engines: {node: '>= 8.0'} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + + dompurify@3.2.7: + resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==} + dotenv@16.6.1: resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} engines: {node: '>=12'} @@ -1391,6 +1695,9 @@ packages: effect@3.18.4: resolution: {integrity: sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA==} + electron-to-chromium@1.5.344: + resolution: {integrity: sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==} + emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -1408,6 +1715,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -1645,6 +1956,10 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -1715,6 +2030,10 @@ packages: resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} engines: {node: '>=14'} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -1722,10 +2041,18 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1745,6 +2072,10 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + indent-string@5.0.0: resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} engines: {node: '>=12'} @@ -1811,6 +2142,9 @@ packages: engines: {node: '>=20'} hasBin: true + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} @@ -1848,14 +2182,31 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + jsdom@28.1.0: + resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsep@1.4.0: resolution: {integrity: sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==} engines: {node: '>= 10.16.0'} + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1874,6 +2225,11 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + jsonpath-plus@10.4.0: resolution: {integrity: sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==} engines: {node: '>=18.0.0'} @@ -1903,6 +2259,13 @@ packages: resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} engines: {node: 20 || >=22} + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1917,10 +2280,18 @@ packages: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} engines: {node: '>=10'} + marked@14.0.0: + resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} + engines: {node: '>= 18'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} @@ -1949,6 +2320,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + minimatch@10.2.2: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} @@ -1987,6 +2362,9 @@ packages: mnemonist@0.40.0: resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==} + monaco-editor@0.55.1: + resolution: {integrity: sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2024,6 +2402,9 @@ packages: encoding: optional: true + node-releases@2.0.38: + resolution: {integrity: sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==} + nopt@5.0.0: resolution: {integrity: sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==} engines: {node: '>=6'} @@ -2091,6 +2472,9 @@ packages: package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -2156,6 +2540,10 @@ packages: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prisma@6.19.2: resolution: {integrity: sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg==} engines: {node: '>=18.18'} @@ -2208,16 +2596,49 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + react-dom@19.2.5: + resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} + peerDependencies: + react: ^19.2.5 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-reconciler@0.33.0: resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} engines: {node: '>=0.10.0'} peerDependencies: react: ^19.2.0 + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.14.2: + resolution: {integrity: sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.14.2: + resolution: {integrity: sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.4: resolution: {integrity: sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==} engines: {node: '>=0.10.0'} + react@19.2.5: + resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} + engines: {node: '>=0.10.0'} + readable-stream@3.6.2: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} @@ -2230,6 +2651,10 @@ packages: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} + redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} @@ -2298,6 +2723,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -2409,6 +2838,9 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: + resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -2446,10 +2878,17 @@ packages: resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} engines: {node: '>=12'} + strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -2501,6 +2940,13 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tldts-core@7.0.28: + resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} + + tldts@7.0.28: + resolution: {integrity: sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw==} + hasBin: true + toad-cache@3.7.0: resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} engines: {node: '>=12'} @@ -2509,9 +2955,17 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} + tough-cookie@6.0.1: + resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.4.0: resolution: {integrity: sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==} engines: {node: '>=18.12'} @@ -2555,10 +3009,20 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + undici@7.25.0: + resolution: {integrity: sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==} + engines: {node: '>=20.18.1'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -2647,9 +3111,25 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -2701,10 +3181,20 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} @@ -2742,19 +3232,144 @@ packages: snapshots: + '@acemir/cssom@0.9.31': {} + + '@adobe/css-tools@4.4.4': {} + '@alcalzone/ansi-tokenize@0.2.5': dependencies: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@6.8.1': + dependencies: + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.2.6 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + + '@babel/code-frame@7.29.0': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.0': {} + + '@babel/core@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-compilation-targets': 7.28.6 + '@babel/helper-module-transforms': 7.28.6(@babel/core@7.29.0) + '@babel/helpers': 7.29.2 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.1': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.28.6': + dependencies: + '@babel/compat-data': 7.29.0 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.28.6': + dependencies: + '@babel/traverse': 7.29.0 + '@babel/types': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.6(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-module-imports': 7.28.6 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.29.0 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.28.6': {} + '@babel/helper-string-parser@7.27.1': {} '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.29.2': + dependencies: + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + '@babel/parser@7.29.0': dependencies: '@babel/types': 7.29.0 + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.29.0)': + dependencies: + '@babel/core': 7.29.0 + '@babel/helper-plugin-utils': 7.28.6 + + '@babel/runtime@7.29.2': {} + + '@babel/template@7.28.6': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@babel/traverse@7.29.0': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/generator': 7.29.1 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.29.0 + '@babel/template': 7.28.6 + '@babel/types': 7.29.0 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + '@babel/types@7.29.0': dependencies: '@babel/helper-string-parser': 7.27.1 @@ -2764,6 +3379,34 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.0.2': {} + + '@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.0.2 + '@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -2872,6 +3515,8 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@exodus/bytes@1.15.0': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.18.0 @@ -3078,6 +3723,16 @@ snapshots: optionalDependencies: '@types/node': 25.3.0 + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/resolve-uri@3.1.2': {} '@jridgewell/sourcemap-codec@1.5.5': {} @@ -3163,6 +3818,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@monaco-editor/loader@1.7.0': + dependencies: + state-local: 1.0.7 + + '@monaco-editor/react@4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@monaco-editor/loader': 1.7.0 + monaco-editor: 0.55.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -3223,6 +3889,8 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@rolldown/pluginutils@1.0.0-rc.3': {} + '@rollup/rollup-android-arm-eabi@4.58.0': optional: true @@ -3300,6 +3968,59 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/runtime': 7.29.2 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/jest-dom@6.9.1': + dependencies: + '@adobe/css-tools': 4.4.4 + aria-query: 5.3.2 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + picocolors: 1.1.1 + redent: 3.0.0 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@babel/runtime': 7.29.2 + '@testing-library/dom': 10.4.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + '@types/react-dom': 19.2.3(@types/react@19.2.14) + + '@types/aria-query@5.0.4': {} + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.29.0 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.29.0 + '@babel/types': 7.29.0 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.29.0 + '@types/bcrypt@5.0.2': dependencies: '@types/node': 25.3.0 @@ -3351,6 +4072,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/react-dom@19.2.3(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -3363,6 +4088,9 @@ snapshots: dependencies: '@types/node': 25.3.0 + '@types/trusted-types@2.0.7': + optional: true + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.1(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -3454,7 +4182,19 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@babel/core': 7.29.0 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.29.0) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.29.0) + '@rolldown/pluginutils': 1.0.0-rc.3 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + transitivePeerDependencies: + - supports-color + + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -3466,7 +4206,7 @@ snapshots: obug: 2.1.1 std-env: 3.10.0 tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: 4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@4.0.18': dependencies: @@ -3560,6 +4300,8 @@ snapshots: dependencies: color-convert: 2.0.1 + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} aproba@2.1.0: {} @@ -3571,6 +4313,12 @@ snapshots: argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + aria-query@5.3.2: {} + asn1@0.2.6: dependencies: safer-buffer: 2.1.2 @@ -3634,6 +4382,8 @@ snapshots: base64-js@1.5.1: {} + baseline-browser-mapping@2.10.23: {} + bcrypt-pbkdf@1.0.2: dependencies: tweetnacl: 0.14.5 @@ -3646,6 +4396,10 @@ snapshots: - encoding - supports-color + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + bl@4.1.0: dependencies: buffer: 5.7.1 @@ -3679,6 +4433,14 @@ snapshots: dependencies: balanced-match: 4.0.3 + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + electron-to-chromium: 1.5.344 + node-releases: 2.0.38 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + buffer@5.7.1: dependencies: base64-js: 1.5.1 @@ -3714,6 +4476,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + caniuse-lite@1.0.30001791: {} + chai@6.2.2: {} chalk@5.6.2: {} @@ -3785,6 +4549,8 @@ snapshots: content-type@1.0.5: {} + convert-source-map@2.0.0: {} + convert-to-spaces@2.0.1: {} cookie-signature@1.2.2: {} @@ -3810,12 +4576,35 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + css.escape@1.5.1: {} + + cssstyle@6.2.0: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1) + css-tree: 3.2.1 + lru-cache: 11.2.6 + csstype@3.2.3: {} + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} deepmerge-ts@7.1.5: {} @@ -3859,6 +4648,14 @@ snapshots: transitivePeerDependencies: - supports-color + dom-accessibility-api@0.5.16: {} + + dom-accessibility-api@0.6.3: {} + + dompurify@3.2.7: + optionalDependencies: + '@types/trusted-types': 2.0.7 + dotenv@16.6.1: {} dunder-proto@1.0.1: @@ -3874,6 +4671,8 @@ snapshots: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 + electron-to-chromium@1.5.344: {} + emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -3886,6 +4685,8 @@ snapshots: dependencies: once: 1.4.0 + entities@8.0.0: {} + environment@1.1.0: {} es-define-property@1.0.1: {} @@ -4205,6 +5006,8 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + gensync@1.0.0-beta.2: {} + get-caller-file@2.0.5: {} get-east-asian-width@1.5.0: {} @@ -4281,6 +5084,12 @@ snapshots: hpagent@1.2.0: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.0 + transitivePeerDependencies: + - '@noble/hashes' + html-escaper@2.0.2: {} http-errors@2.0.1: @@ -4291,6 +5100,13 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -4298,6 +5114,13 @@ snapshots: transitivePeerDependencies: - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -4310,6 +5133,8 @@ snapshots: imurmurhash@0.1.4: {} + indent-string@4.0.0: {} + indent-string@5.0.0: {} inflight@1.0.6: @@ -4385,6 +5210,8 @@ snapshots: is-in-ci@2.0.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@4.0.0: {} is-unicode-supported@2.1.0: {} @@ -4414,12 +5241,43 @@ snapshots: js-tokens@10.0.0: {} + js-tokens@4.0.0: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 + jsdom@28.1.0: + dependencies: + '@acemir/cssom': 0.9.31 + '@asamuzakjp/dom-selector': 6.8.1 + '@bramus/specificity': 2.4.2 + '@exodus/bytes': 1.15.0 + cssstyle: 6.2.0 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.1 + undici: 7.25.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + - supports-color + jsep@1.4.0: {} + jsesc@3.1.0: {} + json-buffer@3.0.1: {} json-schema-ref-resolver@3.0.0: @@ -4434,6 +5292,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json5@2.2.3: {} + jsonpath-plus@10.4.0: dependencies: '@jsep-plugin/assignment': 1.3.0(jsep@1.4.0) @@ -4465,6 +5325,12 @@ snapshots: lru-cache@11.2.6: {} + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4483,8 +5349,12 @@ snapshots: dependencies: semver: 7.7.4 + marked@14.0.0: {} + math-intrinsics@1.1.0: {} + mdn-data@2.27.1: {} + media-typer@1.1.0: {} merge-descriptors@2.0.0: {} @@ -4503,6 +5373,8 @@ snapshots: mimic-fn@2.1.0: {} + min-indent@1.0.1: {} + minimatch@10.2.2: dependencies: brace-expansion: 5.0.2 @@ -4536,6 +5408,11 @@ snapshots: dependencies: obliterator: 2.0.5 + monaco-editor@0.55.1: + dependencies: + dompurify: 3.2.7 + marked: 14.0.0 + ms@2.1.3: {} mute-stream@2.0.0: {} @@ -4557,6 +5434,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-releases@2.0.38: {} + nopt@5.0.0: dependencies: abbrev: 1.1.1 @@ -4624,6 +5503,10 @@ snapshots: package-json-from-dist@1.0.1: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + parseurl@1.3.3: {} patch-console@2.0.0: {} @@ -4685,6 +5568,12 @@ snapshots: prelude-ls@1.2.1: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prisma@6.19.2(typescript@5.9.3): dependencies: '@prisma/config': 6.19.2 @@ -4747,13 +5636,38 @@ snapshots: defu: 6.1.4 destr: 2.0.5 + react-dom@19.2.5(react@19.2.5): + dependencies: + react: 19.2.5 + scheduler: 0.27.0 + + react-is@17.0.2: {} + react-reconciler@0.33.0(react@19.2.4): dependencies: react: 19.2.4 scheduler: 0.27.0 + react-refresh@0.18.0: {} + + react-router-dom@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-router: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + + react-router@7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + cookie: 1.1.1 + react: 19.2.5 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + react@19.2.4: {} + react@19.2.5: {} + readable-stream@3.6.2: dependencies: inherits: 2.0.4 @@ -4764,6 +5678,11 @@ snapshots: real-require@0.2.0: {} + redent@3.0.0: + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + require-directory@2.1.1: {} require-from-string@2.0.2: {} @@ -4849,6 +5768,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-json-parse@4.1.0: {} @@ -4977,6 +5900,8 @@ snapshots: stackback@0.0.2: {} + state-local@1.0.7: {} + statuses@2.0.2: {} std-env@3.10.0: {} @@ -5021,10 +5946,16 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-indent@3.0.0: + dependencies: + min-indent: 1.0.1 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 + symbol-tree@3.2.4: {} + tagged-tag@1.0.0: {} tar-fs@2.1.4: @@ -5104,12 +6035,26 @@ snapshots: tinyrainbow@3.0.3: {} + tldts-core@7.0.28: {} + + tldts@7.0.28: + dependencies: + tldts-core: 7.0.28 + toad-cache@3.7.0: {} toidentifier@1.0.1: {} + tough-cookie@6.0.1: + dependencies: + tldts: 7.0.28 + tr46@0.0.3: {} + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -5147,8 +6092,16 @@ snapshots: undici-types@7.18.2: {} + undici@7.25.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -5174,7 +6127,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.2 - vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2): + vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.18 '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -5198,6 +6151,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.0 + jsdom: 28.1.0 transitivePeerDependencies: - jiti - less @@ -5211,8 +6165,24 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.0 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -5259,8 +6229,14 @@ snapshots: ws@8.19.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + y18n@5.0.8: {} + yallist@3.1.1: {} + yallist@4.0.0: {} yaml@2.8.2: {} diff --git a/src/web/.gitignore b/src/web/.gitignore new file mode 100644 index 0000000..989dd66 --- /dev/null +++ b/src/web/.gitignore @@ -0,0 +1,4 @@ +dist +node_modules +.vite +*.log diff --git a/src/web/index.html b/src/web/index.html new file mode 100644 index 0000000..fafc024 --- /dev/null +++ b/src/web/index.html @@ -0,0 +1,21 @@ + + + + + + mcpctl — prompt editor + + + +
+ + + diff --git a/src/web/package.json b/src/web/package.json new file mode 100644 index 0000000..ce525d4 --- /dev/null +++ b/src/web/package.json @@ -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" + } +} diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx new file mode 100644 index 0000000..7981fa7 --- /dev/null +++ b/src/web/src/App.tsx @@ -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 setTokenPresent(true)} />; + } + + return ( + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ); +} diff --git a/src/web/src/api.ts b/src/web/src/api.ts new file mode 100644 index 0000000..9581938 --- /dev/null +++ b/src/web/src/api.ts @@ -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(method: string, path: string, body?: unknown): Promise { + const token = getToken(); + const headers: Record = { '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; +} + +export const api = { + get: (path: string): Promise => request('GET', path), + post: (path: string, body: unknown): Promise => request('POST', path, body), + put: (path: string, body: unknown): Promise => request('PUT', path, body), + delete: (path: string): Promise => request('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; +} diff --git a/src/web/src/components/Layout.tsx b/src/web/src/components/Layout.tsx new file mode 100644 index 0000000..b951f9e --- /dev/null +++ b/src/web/src/components/Layout.tsx @@ -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 ( +
+
+
mcpctl · prompt editor
+ +
+
+ +
+
+ ); +} + +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 = { + 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', + }, +}; diff --git a/src/web/src/components/Login.tsx b/src/web/src/components/Login.tsx new file mode 100644 index 0000000..0261442 --- /dev/null +++ b/src/web/src/components/Login.tsx @@ -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 ( +
+
+

mcpctl prompt editor

+

Paste a session token or PAT.

+ setValue(e.target.value)} + style={styles.input} + /> + + + {showHelp && ( +
+{`# 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
+`}
+          
+ )} +
+
+ ); +} + +const styles: Record = { + 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, + }, +}; diff --git a/src/web/src/components/PromptEditor.tsx b/src/web/src/components/PromptEditor.tsx new file mode 100644 index 0000000..0729bc4 --- /dev/null +++ b/src/web/src/components/PromptEditor.tsx @@ -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; + /** Lock the name field — used when editing existing prompts (name is the unique key). */ + nameLocked?: boolean; + submitLabel: string; + onSubmit: (draft: PromptDraft) => Promise | 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(null); + + async function handleSubmit(e: React.FormEvent): Promise { + 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 ( +
+
+ + +
+
+ setContent(v ?? '')} + options={{ + minimap: { enabled: false }, + wordWrap: 'on', + fontSize: 13, + automaticLayout: true, + }} + /> +
+ {error !== null &&
{error}
} +
+ + {props.onCancel !== undefined && ( + + )} +
+
+ ); +} + +const styles: Record = { + 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', + }, +}; diff --git a/src/web/src/hooks/useFetch.ts b/src/web/src/hooks/useFetch.ts new file mode 100644 index 0000000..bf26ab3 --- /dev/null +++ b/src/web/src/hooks/useFetch.ts @@ -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 { + data: T | null; + error: Error | null; + loading: boolean; + refetch: () => void; +} + +export function useFetch(fn: () => Promise, deps: unknown[] = []): UseFetchResult { + const [data, setData] = useState(null); + const [error, setError] = useState(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 }; +} diff --git a/src/web/src/main.tsx b/src/web/src/main.tsx new file mode 100644 index 0000000..dd91f32 --- /dev/null +++ b/src/web/src/main.tsx @@ -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( + + + , +); diff --git a/src/web/src/pages/AgentDetail.tsx b/src/web/src/pages/AgentDetail.tsx new file mode 100644 index 0000000..651263f --- /dev/null +++ b/src/web/src/pages/AgentDetail.tsx @@ -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(`/api/v1/agents/${encodeURIComponent(agentName)}`), + [agentName], + ); + const [tab, setTab] = useState('direct'); + + if (agentLoading) return
Loading agent…
; + if (agentError !== null) return
Error: {agentError.message}
; + if (agent === null) return
Not found.
; + + return ( +
+

+ ← Agents +

+

+ {agent.name} + + {agent.llm.name}{agent.project !== null && ` · ${agent.project.name}`} + +

+ {agent.systemPrompt !== '' && ( +
+ System prompt +
{agent.systemPrompt}
+
+ )} +
+ setTab('direct')}>Direct prompts + setTab('personalities')}>Personalities +
+ {tab === 'direct' && } + {tab === 'personalities' && } +
+ ); +} + +function TabButton({ active, onClick, children }: { active: boolean; onClick: () => void; children: React.ReactNode }): React.JSX.Element { + return ( + + ); +} + +// ── Direct prompts tab ── + +function DirectPromptsTab({ agentName }: { agentName: string }): React.JSX.Element { + const { data, error, loading, refetch } = useFetch( + () => api.get(`/api/v1/agents/${encodeURIComponent(agentName)}/prompts`), + [agentName], + ); + const [creating, setCreating] = useState(false); + const [busy, setBusy] = useState(false); + + if (loading) return
Loading…
; + if (error !== null) return
Error: {error.message}
; + const prompts = data ?? []; + + async function handleCreate(draft: PromptDraft): Promise { + 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 { + if (!confirm(`Delete prompt '${name}'?`)) return; + await api.delete(`/api/v1/prompts/${id}`); + refetch(); + } + + return ( +
+

+ Always-on prompts for this agent. Injected after the agent's system prompt and before project prompts. +

+ {!creating && ( + + )} + {creating && ( +
+

New direct prompt

+ setCreating(false)} + busy={busy} + /> +
+ )} + {prompts.length === 0 && !creating &&

No direct prompts yet.

} +
    + {prompts.sort((a, b) => b.priority - a.priority).map((p) => ( +
  • +
    +
    + {p.name} + priority {p.priority} +
    + +
    +
    {p.content}
    +
  • + ))} +
+
+ ); +} + +// ── Personalities tab ── + +function PersonalitiesTab({ agentName }: { agentName: string }): React.JSX.Element { + const { data, error, loading, refetch } = useFetch( + () => api.get(`/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
Loading…
; + if (error !== null) return
Error: {error.message}
; + const personalities = data ?? []; + + async function handleCreate(e: React.FormEvent): Promise { + 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 { + if (!confirm(`Delete personality '${name}'?`)) return; + await api.delete(`/api/v1/personalities/${id}`); + refetch(); + } + + return ( +
+

+ Named overlays of prompts. Pick at chat time with --personality <name> or set + a default on the agent. +

+ {!creating && ( + + )} + {creating && ( +
+

New personality

+
+ + +
+
+ + +
+
+ )} + {personalities.length === 0 && !creating &&

No personalities yet.

} +
    + {personalities.map((p) => ( +
  • +
    +
    + + {p.name} + + + {p.promptCount} prompt{p.promptCount === 1 ? '' : 's'} + +
    + +
    + {p.description !== '' &&
    {p.description}
    } +
  • + ))} +
+
+ ); +} + +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', +}; diff --git a/src/web/src/pages/Agents.tsx b/src/web/src/pages/Agents.tsx new file mode 100644 index 0000000..11b1a87 --- /dev/null +++ b/src/web/src/pages/Agents.tsx @@ -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('/api/v1/agents')); + + if (loading) return
Loading agents…
; + if (error !== null) return
Error: {error.message}
; + const agents = data ?? []; + + return ( +
+

Agents

+

Pick an agent to manage its direct prompts and personalities.

+ {agents.length === 0 &&

No agents yet — create one with mcpctl create agent.

} +
    + {agents.map((a) => ( +
  • + + {a.name} + +
    + LLM: {a.llm.name} + {a.project !== null && <> · Project: {a.project.name}} + {a.defaultPersonality !== null && <> · Default personality: {a.defaultPersonality.name}} +
    + {a.description !== '' && ( +
    {a.description}
    + )} +
  • + ))} +
+
+ ); +} diff --git a/src/web/src/pages/PersonalityDetail.tsx b/src/web/src/pages/PersonalityDetail.tsx new file mode 100644 index 0000000..dc4be8e --- /dev/null +++ b/src/web/src/pages/PersonalityDetail.tsx @@ -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(`/api/v1/personalities/${encodeURIComponent(personalityId)}`), + [personalityId], + ); + const bindings = useFetch( + () => api.get(`/api/v1/personalities/${encodeURIComponent(personalityId)}/prompts`), + [personalityId], + ); + + if (personality.loading || bindings.loading) return
Loading…
; + if (personality.error !== null) return
Error: {personality.error.message}
; + if (personality.data === null) return
Not found.
; + + const p = personality.data; + const bound = bindings.data ?? []; + + return ( +
+

+ + ← {p.agentName} + +

+

+ {p.name} + personality on {p.agentName} +

+ {p.description !== '' &&

{p.description}

} + +

Bound prompts ({bound.length})

+

+ Activated when this personality is selected at chat time. Priority controls order within + the overlay (higher first). +

+ {bound.length === 0 &&

No bound prompts yet.

} +
    + {bound + .slice() + .sort((a, b) => b.priority - a.priority) + .map((b) => ( +
  • +
    +
    + {b.promptName} + priority {b.priority} +
    + +
    +
    {b.promptContent}
    +
  • + ))} +
+ + b.promptId)} + onAttached={bindings.refetch} + /> +
+ ); +} + +async function detach( + personalityId: string, + promptId: string, + promptName: string, + refetch: () => void, +): Promise { + 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( + `/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( + `/api/v1/prompts?project=${encodeURIComponent(agentRow.project.name)}`, + ); + } else { + projectAndGlobal = await api.get(`/api/v1/prompts?scope=global`); + } + // Dedupe; exclude ones already bound. + const seen = new Set(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(null); + async function attach(promptId: string): Promise { + 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 ; + } + + return ( +
+

Attach a prompt

+

+ Eligible: prompts on this agent, prompts in the agent's project, or globals. +

+ {candidates.loading &&
Loading candidates…
} + {candidates.error !== null &&
Error: {candidates.error.message}
} +
    + {(candidates.data ?? []).map((p) => ( +
  • +
    + {p.name} + + {p.agentId !== null ? 'agent-direct' : p.projectId !== null ? 'project' : 'global'} + +
    + +
  • + ))} + {(candidates.data ?? []).length === 0 && !candidates.loading && ( +
  • No eligible prompts to attach.
  • + )} +
+ +
+ ); +} + +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', +}; diff --git a/src/web/src/pages/ProjectPrompts.tsx b/src/web/src/pages/ProjectPrompts.tsx new file mode 100644 index 0000000..24dbd5c --- /dev/null +++ b/src/web/src/pages/ProjectPrompts.tsx @@ -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= 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(`/api/v1/prompts?project=${encodeURIComponent(projectName)}`), + [projectName], + ); + + const [editingId, setEditingId] = useState(null); + const [creating, setCreating] = useState(false); + const [busy, setBusy] = useState(false); + + if (loading) return
Loading prompts…
; + if (error !== null) return
Error: {error.message}
; + 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 { + 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 { + 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 { + if (!confirm(`Delete prompt '${name}'?`)) return; + await api.delete(`/api/v1/prompts/${id}`); + refetch(); + } + + return ( +
+

+ ← Projects +

+

{projectName} · prompts

+ {!creating && ( + + )} + {creating && ( +
+

New prompt

+ setCreating(false)} + busy={busy} + /> +
+ )} + {prompts.length === 0 && !creating && ( +

No prompts in this project yet.

+ )} +
    + {prompts.sort((a, b) => b.priority - a.priority).map((p) => ( +
  • + {editingId === p.id ? ( + <> +

    Edit prompt: {p.name}

    + handleUpdate(p.id, d)} + onCancel={() => setEditingId(null)} + busy={busy} + /> + + ) : ( + <> +
    +
    + {p.name} + + priority {p.priority} + +
    +
    + + +
    +
    +
    {p.content}
    + + )} +
  • + ))} +
+
+ ); +} + +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', +}; diff --git a/src/web/src/pages/Projects.tsx b/src/web/src/pages/Projects.tsx new file mode 100644 index 0000000..9a3dc82 --- /dev/null +++ b/src/web/src/pages/Projects.tsx @@ -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('/api/v1/projects')); + + if (loading) return
Loading projects…
; + if (error !== null) return
Error: {error.message}
; + const projects = data ?? []; + + return ( +
+

Projects

+

Pick a project to edit its prompts.

+ {projects.length === 0 &&

No projects yet.

} +
    + {projects.map((p) => ( +
  • + + {p.name} + + {p.description !== undefined && p.description !== '' && ( + {p.description} + )} +
  • + ))} +
+
+ ); +} diff --git a/src/web/tests/api.test.ts b/src/web/tests/api.test.ts new file mode 100644 index 0000000..212b50c --- /dev/null +++ b/src/web/tests/api.test.ts @@ -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); + }); + + 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(); + }); +}); diff --git a/src/web/tests/login.test.tsx b/src/web/tests/login.test.tsx new file mode 100644 index 0000000..8616a41 --- /dev/null +++ b/src/web/tests/login.test.tsx @@ -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( { 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( { logged = true; }} />); + fireEvent.click(screen.getByText(/Continue/)); + expect(getToken()).toBeNull(); + expect(logged).toBe(false); + }); + + it('toggles the help panel', () => { + render( {}} />); + expect(screen.queryByText(/mcpctl auth login/)).toBeNull(); + fireEvent.click(screen.getByText(/Where do I get a token/)); + expect(screen.getByText(/mcpctl auth login/)).toBeInTheDocument(); + }); +}); diff --git a/src/web/tests/setup.ts b/src/web/tests/setup.ts new file mode 100644 index 0000000..7b0828b --- /dev/null +++ b/src/web/tests/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/src/web/tsconfig.json b/src/web/tsconfig.json new file mode 100644 index 0000000..708feb3 --- /dev/null +++ b/src/web/tsconfig.json @@ -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/**/*"] +} diff --git a/src/web/vite.config.ts b/src/web/vite.config.ts new file mode 100644 index 0000000..9de9b3e --- /dev/null +++ b/src/web/vite.config.ts @@ -0,0 +1,41 @@ +/// +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'], + }, +}); -- 2.49.1 From 4cbf58d212d870c2e7a2c787e1ea7c87482f29d7 Mon Sep 17 00:00:00 2001 From: Michal Date: Sun, 26 Apr 2026 19:48:43 +0100 Subject: [PATCH 6/6] feat(mcpd+deploy): serve web UI at /ui + smoke tests + docs (Stage 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The closing stage. mcpd now hosts the Stage 5 SPA, the Docker image bundles the build artifact, a smoke test exercises the personality HTTP surface end-to-end, and the user-facing docs spell out the mental model. mcpd: - Add @fastify/static dep. - New routes/web-ui.ts: registers /ui/* against a static bundle. Looks for the bundle at $MCPD_WEB_ROOT, then /usr/share/mcpd/web (the Docker image path), then a dev-tree fallback. Logs and skips cleanly if missing — API-only deploys keep working. - SPA fallback: any /ui/ that doesn't match a file falls through to index.html so direct hits to react-router URLs work. - /ui/* falls through to `kind: skip` in mapUrlToPermission, so the static assets are served unauthenticated. Each API call from the SPA still carries the bearer token. Deploy: - Dockerfile.mcpd builds the @mcpctl/web bundle in the same builder stage and copies dist/ to /usr/share/mcpd/web in the runtime image. Smoke (personality.smoke.test.ts): - Live mcpd flow: create secret/llm/agent/personality, attach an agent-direct prompt, verify the binding listing, reject double- attach (409) + foreign-agent prompt (400), set defaultPersonality by name, detach + delete cleanup. Docs: - New docs/personalities.md: VLAN-on-ethernet model, system-block ordering table, three prompt scopes, CLI walkthrough, web UI walkthrough, full API surface, RBAC notes. - agents.md and chat.md cross-link. - README's Agents section gains a Personalities subsection. Test count after Stage 6: mcpd: 801/801 cli: 430/430 web: 7/7 db: 58/62 (4 pre-existing) Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 25 ++ deploy/Dockerfile.mcpd | 10 +- docs/agents.md | 7 + docs/chat.md | 2 + docs/personalities.md | 135 ++++++++ pnpm-lock.yaml | 84 +++++ src/mcpd/package.json | 1 + src/mcpd/src/main.ts | 6 + src/mcpd/src/routes/web-ui.ts | 74 ++++ .../tests/smoke/personality.smoke.test.ts | 322 ++++++++++++++++++ 10 files changed, 665 insertions(+), 1 deletion(-) create mode 100644 docs/personalities.md create mode 100644 src/mcpd/src/routes/web-ui.ts create mode 100644 src/mcplocal/tests/smoke/personality.smoke.test.ts diff --git a/README.md b/README.md index ce12cb4..a6b1cb9 100644 --- a/README.md +++ b/README.md @@ -546,6 +546,31 @@ mcpctl chat reviewer --thread Full reference: [docs/agents.md](docs/agents.md). User-facing chat guide: [docs/chat.md](docs/chat.md). +### Personalities + +Same agent, different prompt bundles per turn. A **Personality** is a named +overlay attached to an agent — when selected at chat time it appends extra +prompts to the system block without replacing the agent's own prompt or +project prompts. Think VLAN on top of ethernet: the underlying agent still +works without one; with one, segmentation kicks in. + +```bash +# Make a personality on an existing agent +mcpctl create personality grumpy --agent reviewer --description "Be terse and slightly grumpy" + +# Add an agent-direct prompt (always-on for this agent — no toggle) +mcpctl create prompt always-terse --agent reviewer --content "Always be terse." --priority 8 + +# Use it +mcpctl chat reviewer --personality grumpy +``` + +For binding prompts to personalities and the API surface, see +[docs/personalities.md](docs/personalities.md). The browser editor at +`https://mcpctl.ad.itaz.eu/ui/` covers the same flow with Monaco-based +prompt editing — paste a session token (`mcpctl auth login`) or PAT to log +in. + ## Commands ```bash diff --git a/deploy/Dockerfile.mcpd b/deploy/Dockerfile.mcpd index ec3ca80..29c6c76 100644 --- a/deploy/Dockerfile.mcpd +++ b/deploy/Dockerfile.mcpd @@ -10,6 +10,7 @@ COPY pnpm-workspace.yaml pnpm-lock.yaml package.json tsconfig.base.json ./ COPY src/mcpd/package.json src/mcpd/tsconfig.json src/mcpd/ COPY src/db/package.json src/db/tsconfig.json src/db/ COPY src/shared/package.json src/shared/tsconfig.json src/shared/ +COPY src/web/package.json src/web/tsconfig.json src/web/ # Install all dependencies RUN pnpm install --frozen-lockfile @@ -19,10 +20,13 @@ COPY src/mcpd/src/ src/mcpd/src/ COPY src/db/src/ src/db/src/ COPY src/db/prisma/ src/db/prisma/ COPY src/shared/src/ src/shared/src/ +COPY src/web/src/ src/web/src/ +COPY src/web/index.html src/web/vite.config.ts src/web/ -# Generate Prisma client and build TypeScript +# Generate Prisma client and build TypeScript + web SPA RUN pnpm -F @mcpctl/db db:generate RUN pnpm -F @mcpctl/shared build && pnpm -F @mcpctl/db build && pnpm -F @mcpctl/mcpd build +RUN pnpm -F @mcpctl/web build # Stage 2: Production runtime FROM node:20-alpine @@ -50,6 +54,10 @@ COPY --from=builder /app/src/shared/dist/ src/shared/dist/ COPY --from=builder /app/src/db/dist/ src/db/dist/ COPY --from=builder /app/src/mcpd/dist/ src/mcpd/dist/ +# Copy the web SPA bundle. registerWebUi() looks for it at this path +# (or wherever MCPD_WEB_ROOT is set). +COPY --from=builder /app/src/web/dist/ /usr/share/mcpd/web/ + # Copy templates for seeding COPY templates/ templates/ diff --git a/docs/agents.md b/docs/agents.md index fedab5e..e86df3d 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -195,3 +195,10 @@ mcpctl chat reviewer `tool_use` / `tool_result` blocks. Use an OpenAI-compatible provider (LiteLLM, vLLM, OpenAI) for agents that need tool calling until that translation lands. + +## See also + +- [personalities.md](./personalities.md) — named overlays of prompts on + top of an agent. Same agent, different prompt bundles, picked per-turn + via `--personality ` or `agent.defaultPersonality`. +- [chat.md](./chat.md) — `mcpctl chat` flow and LiteLLM-style flags. diff --git a/docs/chat.md b/docs/chat.md index c93f270..8134736 100644 --- a/docs/chat.md +++ b/docs/chat.md @@ -27,6 +27,8 @@ back to the agent. --system # replace agent.systemPrompt for this session --system-file # read --system text from a file --system-append # append to the agent system block (after project Prompts) +--personality # apply a personality overlay for this turn + # (additive — see docs/personalities.md) --temperature # 0..2 --top-p # 0..1 --top-k # integer; Anthropic-only, OpenAI ignores diff --git a/docs/personalities.md b/docs/personalities.md new file mode 100644 index 0000000..f4d1693 --- /dev/null +++ b/docs/personalities.md @@ -0,0 +1,135 @@ +# Personalities & agent-direct prompts + +A **personality** is a named overlay of prompts on top of an existing +agent. Same agent, same LLM, same `systemPrompt` — but a different bundle +of additional context injected at chat time. + +The mental model is a VLAN on top of ethernet: ethernet works on its own, +and a VLAN tag adds segmentation without replacing the underlying link. +Without a personality, an agent runs exactly as before. With one selected, +its bound prompts get appended to the system block. + +## What goes into the system block + +When you call an agent's chat endpoint, mcpd assembles the system block in +this order (top wins by appearing first in the prompt): + +``` +agent.systemPrompt ++ agent-direct prompts (Prompt.agentId == agent.id, priority desc) ++ project prompts (Prompt.projectId == agent.projectId, priority desc) ++ personality-bound prompts (PersonalityPrompt[chosen], priority desc) ++ systemAppend (per-call override, --system-append) +``` + +Picking a personality is per-turn. Either: + +- pass `--personality ` on the CLI (or `personality: ""` in + the chat request body), or +- set `agent.defaultPersonalityId` on the agent — used when no + `--personality` flag is given. + +Without either, today's behavior holds: agent + project prompts only. + +## Three prompt scopes + +A `Prompt` row attaches to **at most one** of `projectId` or `agentId`. +Every prompt fits exactly one of these slots: + +| Scope | `projectId` | `agentId` | Where it shows up | +|----------------|-------------|-----------|------------------------------------------------------------| +| Global | `null` | `null` | Any chat (passes the personality's "in scope" check) | +| Project | set | `null` | All agents whose `projectId` matches | +| Agent-direct | `null` | set | Only this agent. Always-on overlay, no toggle | + +Personality bindings (`PersonalityPrompt`) further select which of those +prompts get injected when that personality is active. The service layer +enforces a scope rule: a prompt can only be bound to a personality if +it's already in-scope for that agent (agent-direct, agent's project, or +global). Foreign-project prompts are rejected with HTTP 400. + +## CLI + +```fish +# Make a personality on an existing agent +mcpctl create personality grumpy --agent reviewer --description "Be terse and slightly grumpy" + +# Add an agent-direct prompt (always-on for this agent) +mcpctl create prompt always-terse --agent reviewer --content "Always be terse." --priority 8 + +# Bind the prompt to the personality (HTTP for now — CLI subcommand to come) +PERSONALITY_ID=$(mcpctl get personalities -o json | jq -r '.[] | select(.name=="grumpy") | .id') +PROMPT_ID=$(mcpctl get prompts always-terse -o json | jq -r '.[0].id') +curl -sf -H "Authorization: Bearer $(jq -r .token ~/.mcpctl/credentials)" \ + -H "Content-Type: application/json" \ + -X POST "https://mcpctl.ad.itaz.eu/api/v1/personalities/${PERSONALITY_ID}/prompts" \ + -d "{\"promptId\": \"${PROMPT_ID}\", \"priority\": 9}" + +# Use it +mcpctl chat reviewer --personality grumpy +> what's wrong with this code? + +# Make it the default for this agent +mcpctl edit agent reviewer +# … in the YAML editor, set: +# defaultPersonality: +# name: grumpy +``` + +The chat banner shows which personality (if any) is active before the +first prompt: + +``` +──────────────────────────────────────────────────────────── +Agent: reviewer — code review agent +LLM: qwen3-thinking Project: code-quality +Personality: grumpy (--personality) +System prompt: + You are a senior code reviewer. Be terse... +──────────────────────────────────────────────────────────── +``` + +## Web UI + +The browser editor at `https://mcpctl.ad.itaz.eu/ui/` covers the same +operations with Monaco for prompt editing: + +- **Projects → :name → prompts**: project-scoped prompt CRUD. +- **Agents → :name → Direct prompts**: agent-direct prompt CRUD. +- **Agents → :name → Personalities**: list, create, drill into a + personality to bind/unbind prompts. The "attach prompt" picker only + shows in-scope candidates (agent-direct, same-project, or global). + +The web UI uses the same bearer token as the CLI — paste a session +token (`mcpctl auth login` writes one to `~/.mcpctl/credentials`) or +mint a long-lived PAT (`mcpctl create mcptoken …`). The token is kept +in `localStorage`; logout clears it. + +## API surface + +``` +GET /api/v1/personalities +GET /api/v1/personalities?agent= +GET /api/v1/agents/:agentName/personalities +POST /api/v1/agents/:agentName/personalities +GET /api/v1/personalities/:id +PUT /api/v1/personalities/:id +DELETE /api/v1/personalities/:id +GET /api/v1/personalities/:id/prompts +POST /api/v1/personalities/:id/prompts body: { promptId, priority? } +DELETE /api/v1/personalities/:id/prompts/:promptId + +GET /api/v1/agents/:agentName/prompts # agent-direct prompts only +POST /api/v1/prompts # body: { name, content, agent: , priority? } + # XOR with project: +``` + +Chat request body now accepts an optional `personality: ""`. RBAC: +all personality endpoints inherit `agents:view/edit/create/delete`. There +is no separate `personalities` resource in RBAC bindings — managing a +personality is part of managing the parent agent. + +## See also + +- [agents.md](./agents.md) — the parent resource. +- [chat.md](./chat.md) — `mcpctl chat` flow + LiteLLM-style flags. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e64646d..5b44c75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -112,6 +112,9 @@ importers: '@fastify/rate-limit': specifier: ^10.0.0 version: 10.3.0 + '@fastify/static': + specifier: ^8.0.0 + version: 8.3.0 '@kubernetes/client-node': specifier: ^1.4.0 version: 1.4.0 @@ -573,6 +576,9 @@ packages: '@noble/hashes': optional: true + '@fastify/accept-negotiator@2.0.1': + resolution: {integrity: sha512-/c/TW2bO/v9JeEgoD/g1G5GxGeCF1Hafdf79WPmUlgYiBXummY0oX3VVq4yFkKKVBKDNlaDUYoab7g38RpPqCQ==} + '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} @@ -600,6 +606,12 @@ packages: '@fastify/rate-limit@10.3.0': resolution: {integrity: sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q==} + '@fastify/send@4.1.0': + resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} + + '@fastify/static@8.3.0': + resolution: {integrity: sha512-yKxviR5PH1OKNnisIzZKmgZSus0r2OZb8qCSbqmw34aolT4g3UlzYfeBRym+HJ1J471CR8e2ldNub4PubD1coA==} + '@grpc/grpc-js@1.14.3': resolution: {integrity: sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==} engines: {node: '>=12.10.0'} @@ -776,6 +788,10 @@ packages: '@types/node': optional: true + '@isaacs/cliui@9.0.0': + resolution: {integrity: sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg==} + engines: {node: '>=18'} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -1555,6 +1571,10 @@ packages: console-control-strings@1.1.0: resolution: {integrity: sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -1921,6 +1941,10 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -1987,6 +2011,12 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@11.1.0: + resolution: {integrity: sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==} + engines: {node: 20 || >=22} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + glob@13.0.6: resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} engines: {node: 18 || 20 || >=22} @@ -2172,6 +2202,10 @@ packages: resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} engines: {node: '>=8'} + jackspeak@4.2.3: + resolution: {integrity: sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg==} + engines: {node: 20 || >=22} + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2316,6 +2350,11 @@ packages: resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} engines: {node: '>=18'} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -3517,6 +3556,8 @@ snapshots: '@exodus/bytes@1.15.0': {} + '@fastify/accept-negotiator@2.0.1': {} + '@fastify/ajv-compiler@4.0.5': dependencies: ajv: 8.18.0 @@ -3556,6 +3597,23 @@ snapshots: fastify-plugin: 5.1.0 toad-cache: 3.7.0 + '@fastify/send@4.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@8.3.0': + dependencies: + '@fastify/accept-negotiator': 2.0.1 + '@fastify/send': 4.1.0 + content-disposition: 0.5.4 + fastify-plugin: 5.1.0 + fastq: 1.20.1 + glob: 11.1.0 + '@grpc/grpc-js@1.14.3': dependencies: '@grpc/proto-loader': 0.8.0 @@ -3723,6 +3781,8 @@ snapshots: optionalDependencies: '@types/node': 25.3.0 + '@isaacs/cliui@9.0.0': {} + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -4545,6 +4605,10 @@ snapshots: console-control-strings@1.1.0: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -4969,6 +5033,11 @@ snapshots: flatted@3.3.3: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -5047,6 +5116,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@11.1.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.2.3 + minimatch: 10.2.2 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.2 + glob@13.0.6: dependencies: minimatch: 10.2.2 @@ -5235,6 +5313,10 @@ snapshots: html-escaper: 2.0.2 istanbul-lib-report: 3.0.1 + jackspeak@4.2.3: + dependencies: + '@isaacs/cliui': 9.0.0 + jiti@2.6.1: {} jose@6.1.3: {} @@ -5371,6 +5453,8 @@ snapshots: dependencies: mime-db: 1.54.0 + mime@3.0.0: {} + mimic-fn@2.1.0: {} min-indent@1.0.1: {} diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 40950ba..3f51592 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -17,6 +17,7 @@ "@fastify/cors": "^10.0.0", "@fastify/helmet": "^12.0.0", "@fastify/rate-limit": "^10.0.0", + "@fastify/static": "^8.0.0", "@kubernetes/client-node": "^1.4.0", "@mcpctl/db": "workspace:*", "@mcpctl/shared": "workspace:*", diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 210a044..fc05068 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -47,6 +47,7 @@ import { PromptRequestRepository } from './repositories/prompt-request.repositor import { PersonalityRepository } from './repositories/personality.repository.js'; import { PersonalityService } from './services/personality.service.js'; import { registerPersonalityRoutes } from './routes/personalities.js'; +import { registerWebUi } from './routes/web-ui.js'; import { bootstrapSystemProject } from './bootstrap/system-project.js'; import { McpServerService, @@ -725,6 +726,11 @@ async function main(): Promise { }); }); + // Web UI: served from /ui (static SPA bundle). Falls through to API + // routes when the prefix doesn't match. Skipped silently if the bundle + // isn't installed (dev tree without `pnpm --filter @mcpctl/web build`). + await registerWebUi(app); + // Start await app.listen({ port: config.port, host: config.host }); app.log.info(`mcpd listening on ${config.host}:${config.port}`); diff --git a/src/mcpd/src/routes/web-ui.ts b/src/mcpd/src/routes/web-ui.ts new file mode 100644 index 0000000..d3c1ad0 --- /dev/null +++ b/src/mcpd/src/routes/web-ui.ts @@ -0,0 +1,74 @@ +/** + * /ui — serves the @mcpctl/web SPA bundle. + * + * In production the bundle lives at /usr/share/mcpd/web (installed by the + * RPM in Stage 6); in dev it lives at /src/web/dist after a + * `pnpm --filter @mcpctl/web build`. The location is overridable via the + * `MCPD_WEB_ROOT` env var so deployers can move it freely. + * + * If the directory is missing we log a warning and skip — mcpd still serves + * the API. That lets the dev tree run without forcing a web build first. + * + * SPA routing: anything under /ui/ that's not a file falls back to + * index.html so client-side react-router routes work on direct hits. + */ +import path from 'node:path'; +import { existsSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import type { FastifyInstance } from 'fastify'; +import fastifyStatic from '@fastify/static'; + +const DEFAULT_PROD_ROOT = '/usr/share/mcpd/web'; + +function resolveWebRoot(): string | null { + const fromEnv = process.env['MCPD_WEB_ROOT']; + if (fromEnv !== undefined && fromEnv !== '') { + return existsSync(fromEnv) ? fromEnv : null; + } + if (existsSync(DEFAULT_PROD_ROOT)) return DEFAULT_PROD_ROOT; + + // Dev fallback: walk up from this file to find /src/web/dist. + // After bun compile this path doesn't resolve, which is fine — prod uses + // DEFAULT_PROD_ROOT or MCPD_WEB_ROOT instead. + try { + const here = path.dirname(fileURLToPath(import.meta.url)); + const candidate = path.resolve(here, '../../../web/dist'); + if (existsSync(candidate)) return candidate; + } catch { + // import.meta.url unavailable in some bundled envs — skip. + } + return null; +} + +export async function registerWebUi(app: FastifyInstance): Promise { + const root = resolveWebRoot(); + if (root === null) { + app.log.warn( + `web UI bundle not found (set MCPD_WEB_ROOT, or place a build at ${DEFAULT_PROD_ROOT}); /ui will return 404`, + ); + return; + } + if (!statSync(root).isDirectory()) { + app.log.warn({ root }, 'web UI root is not a directory; /ui will return 404'); + return; + } + + await app.register(fastifyStatic, { + root, + prefix: '/ui/', + wildcard: false, + decorateReply: false, + }); + + // SPA fallback — react-router URLs like /ui/agents/foo/personalities/bar + // need index.html to bootstrap the app. + app.get('/ui/*', (_request, reply) => { + return reply.sendFile('index.html', root); + }); + // Cover the bare /ui (no trailing slash) too. + app.get('/ui', (_request, reply) => { + return reply.redirect('/ui/'); + }); + + app.log.info({ root }, 'web UI mounted at /ui'); +} diff --git a/src/mcplocal/tests/smoke/personality.smoke.test.ts b/src/mcplocal/tests/smoke/personality.smoke.test.ts new file mode 100644 index 0000000..5c22d4f --- /dev/null +++ b/src/mcplocal/tests/smoke/personality.smoke.test.ts @@ -0,0 +1,322 @@ +/** + * Smoke tests: Personality + agent-direct prompts against a live mcpd. + * + * Validates Stages 1-4 end-to-end without needing a live LLM upstream: + * 1. Create the supporting Secret + Llm + Agent (mcpctl CLI). + * 2. Create a Personality on the agent (POST /api/v1/agents/:name/personalities). + * 3. Create an agent-direct prompt (POST /api/v1/prompts with `agent: name`). + * 4. Attach the prompt; verify the binding shows up. + * 5. Reject double-attach (409) and out-of-scope attach (400). + * 6. PUT the agent's defaultPersonality by name. + * 7. Cleanup: detach, delete personality, delete agent, delete llm/secret. + * + * The chat-time overlay path is covered by the new mcpd unit tests + * (chat-service.test.ts); a future agent-chat smoke run with the right + * env vars exercises it through the full SSE pipe. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { execSync } from 'node:child_process'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SUFFIX = Date.now().toString(36); +const SECRET_NAME = `smoke-pers-sec-${SUFFIX}`; +const LLM_NAME = `smoke-pers-llm-${SUFFIX}`; +const AGENT_NAME = `smoke-pers-agent-${SUFFIX}`; +const PERSONALITY_NAME = `smoke-pers-${SUFFIX}`; +const DIRECT_PROMPT_NAME = `smoke-pers-direct-${SUFFIX}`; + +interface CliResult { code: number; stdout: string; stderr: string } + +function run(args: string): CliResult { + try { + const stdout = execSync(`mcpctl --direct ${args}`, { + encoding: 'utf-8', + timeout: 30_000, + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { code: 0, stdout: stdout.trim(), stderr: '' }; + } catch (err) { + const e = err as { status?: number; stdout?: Buffer | string; stderr?: Buffer | string }; + return { + code: e.status ?? 1, + stdout: e.stdout ? (typeof e.stdout === 'string' ? e.stdout : e.stdout.toString('utf-8')) : '', + stderr: e.stderr ? (typeof e.stderr === 'string' ? e.stderr : e.stderr.toString('utf-8')) : '', + }; + } +} + +function healthz(url: string, timeoutMs = 5000): Promise { + return new Promise((resolve) => { + const parsed = new URL(`${url.replace(/\/$/, '')}/healthz`); + const driver = parsed.protocol === 'https:' ? https : http; + const req = driver.get( + { + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname, + timeout: timeoutMs, + }, + (res) => { resolve((res.statusCode ?? 500) < 500); res.resume(); }, + ); + req.on('error', () => resolve(false)); + req.on('timeout', () => { req.destroy(); resolve(false); }); + }); +} + +let mcpdUp = false; +let createdPromptId: string | null = null; +let createdPersonalityId: string | null = null; + +describe('personality smoke', () => { + beforeAll(async () => { + mcpdUp = await healthz(MCPD_URL); + if (!mcpdUp) { + // eslint-disable-next-line no-console + console.warn(`\n ○ personality smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); + } + }, 20_000); + + afterAll(async () => { + if (!mcpdUp) return; + if (createdPersonalityId !== null) { + await httpRequest('DELETE', `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}`, undefined); + } + if (createdPromptId !== null) { + await httpRequest('DELETE', `${MCPD_URL}/api/v1/prompts/${createdPromptId}`, undefined); + } + run(`delete agent ${AGENT_NAME}`); + run(`delete llm ${LLM_NAME}`); + run(`delete secret ${SECRET_NAME}`); + }); + + it('seeds Secret + Llm + Agent', () => { + if (!mcpdUp) return; + run(`delete secret ${SECRET_NAME}`); + run(`delete llm ${LLM_NAME}`); + run(`delete agent ${AGENT_NAME}`); + + expect(run(`create secret ${SECRET_NAME} --data API_KEY=sk-fake`).code).toBe(0); + expect(run([ + `create llm ${LLM_NAME}`, + '--type openai', + '--model gpt-4o-mini', + '--url http://localhost:9999', + `--api-key-ref ${SECRET_NAME}/API_KEY`, + ].join(' ')).code).toBe(0); + expect(run([ + `create agent ${AGENT_NAME}`, + `--llm ${LLM_NAME}`, + `--description "smoke personality agent"`, + ].join(' ')).code).toBe(0); + }); + + it('creates an agent-direct prompt', async () => { + if (!mcpdUp) return; + const res = await httpRequest('POST', `${MCPD_URL}/api/v1/prompts`, { + name: DIRECT_PROMPT_NAME, + content: 'Always be terse.', + agent: AGENT_NAME, + priority: 8, + }); + expect(res.status).toBe(201); + const body = JSON.parse(res.body) as { id: string; agentId: string }; + expect(body.agentId).toBeTruthy(); + createdPromptId = body.id; + }); + + it('lists agent-direct prompts via GET /api/v1/agents/:name/prompts', async () => { + if (!mcpdUp) return; + const res = await httpRequest('GET', `${MCPD_URL}/api/v1/agents/${AGENT_NAME}/prompts`, undefined); + expect(res.status).toBe(200); + const rows = JSON.parse(res.body) as Array<{ name: string }>; + expect(rows.some((r) => r.name === DIRECT_PROMPT_NAME)).toBe(true); + }); + + it('creates a personality on the agent', async () => { + if (!mcpdUp) return; + const res = await httpRequest( + 'POST', + `${MCPD_URL}/api/v1/agents/${AGENT_NAME}/personalities`, + { + name: PERSONALITY_NAME, + description: 'smoke personality', + priority: 7, + }, + ); + expect(res.status, res.body).toBe(201); + const body = JSON.parse(res.body) as { id: string; name: string; promptCount: number }; + expect(body.name).toBe(PERSONALITY_NAME); + expect(body.promptCount).toBe(0); + createdPersonalityId = body.id; + }); + + it('rejects duplicate personality name on the same agent (409)', async () => { + if (!mcpdUp) return; + const res = await httpRequest( + 'POST', + `${MCPD_URL}/api/v1/agents/${AGENT_NAME}/personalities`, + { name: PERSONALITY_NAME }, + ); + expect(res.status).toBe(409); + }); + + it('lists the personality via /api/v1/personalities and the per-agent route', async () => { + if (!mcpdUp) return; + const all = await httpRequest('GET', `${MCPD_URL}/api/v1/personalities`, undefined); + expect(all.status).toBe(200); + const allRows = JSON.parse(all.body) as Array<{ name: string; agentName: string }>; + expect(allRows.some((r) => r.name === PERSONALITY_NAME && r.agentName === AGENT_NAME)).toBe(true); + + const perAgent = await httpRequest( + 'GET', + `${MCPD_URL}/api/v1/agents/${AGENT_NAME}/personalities`, + undefined, + ); + expect(perAgent.status).toBe(200); + const perAgentRows = JSON.parse(perAgent.body) as Array<{ name: string }>; + expect(perAgentRows.map((r) => r.name)).toContain(PERSONALITY_NAME); + }); + + it('attaches the agent-direct prompt and lists the binding', async () => { + if (!mcpdUp || createdPersonalityId === null || createdPromptId === null) return; + const attach = await httpRequest( + 'POST', + `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`, + { promptId: createdPromptId, priority: 9 }, + ); + expect(attach.status, attach.body).toBe(201); + + const list = await httpRequest( + 'GET', + `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`, + undefined, + ); + expect(list.status).toBe(200); + const rows = JSON.parse(list.body) as Array<{ promptName: string; priority: number }>; + const found = rows.find((r) => r.promptName === DIRECT_PROMPT_NAME); + expect(found, `binding for ${DIRECT_PROMPT_NAME} must be present`).toBeDefined(); + expect(found!.priority).toBe(9); + }); + + it('rejects double-attach of the same prompt (409)', async () => { + if (!mcpdUp || createdPersonalityId === null || createdPromptId === null) return; + const res = await httpRequest( + 'POST', + `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`, + { promptId: createdPromptId }, + ); + expect(res.status).toBe(409); + }); + + it('rejects attaching a prompt belonging to a different agent (400)', async () => { + if (!mcpdUp || createdPersonalityId === null) return; + // Spawn a second agent + a prompt direct on it; foreign attach must 400. + const otherAgent = `smoke-pers-other-${SUFFIX}`; + expect(run([ + `create agent ${otherAgent}`, + `--llm ${LLM_NAME}`, + ].join(' ')).code).toBe(0); + const foreignPrompt = await httpRequest('POST', `${MCPD_URL}/api/v1/prompts`, { + name: `smoke-pers-foreign-${SUFFIX}`, + content: 'foreign', + agent: otherAgent, + }); + expect(foreignPrompt.status).toBe(201); + const foreignId = (JSON.parse(foreignPrompt.body) as { id: string }).id; + + try { + const res = await httpRequest( + 'POST', + `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts`, + { promptId: foreignId }, + ); + expect(res.status).toBe(400); + } finally { + await httpRequest('DELETE', `${MCPD_URL}/api/v1/prompts/${foreignId}`, undefined); + run(`delete agent ${otherAgent}`); + } + }); + + it('sets defaultPersonality on the agent by name', async () => { + if (!mcpdUp) return; + // Resolve agent id for PUT. + const res = await httpRequest('GET', `${MCPD_URL}/api/v1/agents/${AGENT_NAME}`, undefined); + expect(res.status).toBe(200); + const agent = JSON.parse(res.body) as { id: string }; + + const put = await httpRequest('PUT', `${MCPD_URL}/api/v1/agents/${agent.id}`, { + defaultPersonality: { name: PERSONALITY_NAME }, + }); + expect(put.status, put.body).toBe(200); + const updated = JSON.parse(put.body) as { defaultPersonality: { name: string } | null }; + expect(updated.defaultPersonality?.name).toBe(PERSONALITY_NAME); + }); + + it('detaches the prompt and deletes the personality', async () => { + if (!mcpdUp || createdPersonalityId === null || createdPromptId === null) return; + const detach = await httpRequest( + 'DELETE', + `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}/prompts/${createdPromptId}`, + undefined, + ); + expect(detach.status).toBe(204); + + const del = await httpRequest( + 'DELETE', + `${MCPD_URL}/api/v1/personalities/${createdPersonalityId}`, + undefined, + ); + expect(del.status).toBe(204); + createdPersonalityId = null; + }); +}); + +interface HttpResponse { status: number; body: string } + +function httpRequest(method: string, urlStr: string, body: unknown): Promise { + return new Promise((resolve, reject) => { + const tokenRaw = readToken(); + const parsed = new URL(urlStr); + const driver = parsed.protocol === 'https:' ? https : http; + const headers: Record = { + Accept: 'application/json', + ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(tokenRaw !== null ? { Authorization: `Bearer ${tokenRaw}` } : {}), + }; + const req = driver.request({ + hostname: parsed.hostname, + port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80), + path: parsed.pathname + parsed.search, + method, + headers, + timeout: 15_000, + }, (res) => { + const chunks: Buffer[] = []; + res.on('data', (c: Buffer) => chunks.push(c)); + res.on('end', () => { + resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf-8') }); + }); + }); + req.on('error', reject); + req.on('timeout', () => { req.destroy(); reject(new Error(`httpRequest timeout: ${method} ${urlStr}`)); }); + if (body !== undefined) req.write(JSON.stringify(body)); + req.end(); + }); +} + +function readToken(): string | null { + try { + const home = process.env.HOME ?? ''; + const path = `${home}/.mcpctl/credentials`; + // eslint-disable-next-line @typescript-eslint/no-require-imports + const fs = require('node:fs') as typeof import('node:fs'); + if (!fs.existsSync(path)) return null; + const raw = fs.readFileSync(path, 'utf-8'); + const parsed = JSON.parse(raw) as { token?: string }; + return parsed.token ?? null; + } catch { + return null; + } +} -- 2.49.1