From 21f8bede2e1ff4a19745a638321e9c28b00fb9bc Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 29 Apr 2026 00:46:06 +0100 Subject: [PATCH 01/18] feat(mcpd+db): visibility scope + ownership for Llms and Agents (v7 Stage 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the schema + service-layer machinery for per-user RBAC scoping of virtual Llms and Agents. Without this, anyone with `view:llms` sees every other user's published model — fine for a single-user homelab, wrong for org use where workstation-published models or paid keys aren't meant to be broadcast. Schema: - Llm: new `ownerId String?` + `visibility String @default("public")`. NULL ownerId on legacy rows is treated as public for back-compat. - Agent: `visibility String @default("public")` (Agent already has `ownerId`, required). - Composite index `(visibility, ownerId)` on both tables for the list-filter hot path. - Migration backfills both columns to 'public' so pre-v7 setups behave identically post-deploy. Service layer: - New `Viewer` / `AgentViewer` shape: `{ userId, wildcard, allowedNames }`. The route layer computes this from `request.userId` + `RbacService.getAllowedScope` and passes it down. NULL viewer = skip the filter (internal callers — cron sweeps, audit, tests). - `isLlmVisibleTo` / `isAgentVisibleTo` pure predicates encode the decision tree: visibility=public → visible (RBAC layer above already passed) viewer=null OR wildcard → visible ownerId === viewer.userId → visible row.name in viewer.allowedNames → visible else → hidden - LlmService.list/getById/getByName + AgentService equivalents accept an optional Viewer arg and apply the predicate. Get-style methods 404 (not 403) on hidden rows so name enumeration via differential status is impossible. Repositories: CreateInput/UpdateInput types gained `ownerId`/ `visibility` (Llm) and `visibility` (Agent). Update is in place; ownerId is set-once at create time. Tests: - 13 unit tests on the predicate covering every branch (null viewer, public, wildcard, owner, name-scoped grant, foreign private, legacy null-ownerId). - mcpd 908/908 (was 893; +15 across the merge windows + this PR). Stage 2 (next): route plumbing — every list/get endpoint needs to build the Viewer from the request and pass it through. mcplocal virtuals default to visibility=private on register. CLI adds a VISIBILITY column and a --visibility flag. yaml round-trip preserves the field. --- .../migration.sql | 25 +++++ src/db/prisma/schema.prisma | 37 ++++++ src/mcpd/src/repositories/agent.repository.ts | 7 ++ src/mcpd/src/repositories/llm.repository.ts | 12 ++ src/mcpd/src/services/agent.service.ts | 42 ++++++- src/mcpd/src/services/llm.service.ts | 69 +++++++++++- src/mcpd/tests/visibility-filter.test.ts | 105 ++++++++++++++++++ 7 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql create mode 100644 src/mcpd/tests/visibility-filter.test.ts diff --git a/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql b/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql new file mode 100644 index 0000000..f577bdb --- /dev/null +++ b/src/db/prisma/migrations/20260428233954_add_visibility_v7/migration.sql @@ -0,0 +1,25 @@ +-- v7: per-user RBAC scoping for virtual Llms and Agents. +-- +-- `Llm.ownerId` is new — we don't have a record of who created legacy +-- rows, so existing data is left NULL (treated as "no owner, public"). +-- The list/get filter in the service layer handles NULL ownerId +-- correctly: a NULL-owner public row stays visible to everyone. +-- +-- `Llm.visibility` and `Agent.visibility` default to 'public' so the +-- backfill is automatic — pre-v7 setups continue to behave identically. +-- New rows created post-deploy carry the value the service writes +-- (mcplocal virtuals → 'private'; CLI `mcpctl create llm` → 'public' +-- by default unless `--visibility private` is passed). + +ALTER TABLE "Llm" + ADD COLUMN "ownerId" TEXT, + ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public'; + +ALTER TABLE "Agent" + ADD COLUMN "visibility" TEXT NOT NULL DEFAULT 'public'; + +-- Composite index supports the list-filter hot path: +-- `WHERE visibility='public' OR ownerId=$1` +-- on tables that may grow as more publishers / users come online. +CREATE INDEX "Llm_visibility_ownerId_idx" ON "Llm"("visibility", "ownerId"); +CREATE INDEX "Agent_visibility_ownerId_idx" ON "Agent"("visibility", "ownerId"); diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4289ae0..1c70d95 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -225,6 +225,30 @@ model Llm { lastHeartbeatAt DateTime? // bumped on every publisher heartbeat status LlmStatus @default(active) inactiveSince DateTime? // when status flipped from active; used for 4-h GC + // ── Per-user RBAC scoping (v7) ── + // `ownerId` records who created/published the row. NULL on legacy rows + // (those created before the v7 migration) — those continue to behave + // as `visibility=public` for back-compat. New rows always carry an + // ownerId set by the service layer (`User.id` of the authenticated + // caller, or the publishing mcplocal user for virtuals). + // + // `visibility` controls who can see / use the row: + // - 'public' : anyone with the resource grant (`view:llms`, + // `run:llms:`, etc.) sees it. Legacy default + // mirrors today's behavior — explicit + // `mcpctl create llm` calls keep this default. + // - 'private' : only the owner sees it by default; other users need + // an explicit name-scoped RBAC binding + // (`view:llms:`, `run:llms:`). The list + // endpoint hides foreign-private rows from + // unauthorized callers; get/describe returns 404 to + // prevent name enumeration. + // + // mcplocal-published virtual Llms default to 'private' on register — + // a workstation-published model isn't typically meant for the whole + // org until the publisher explicitly shares it. See docs/virtual-llms.md. + ownerId String? + visibility String @default("public") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -238,6 +262,10 @@ model Llm { @@index([kind, status]) @@index([providerSessionId]) @@index([poolName]) + // List filter on the hot path: "rows visible to caller X" decomposes + // into `visibility='public' OR ownerId=X` + an RBAC join. Composite + // index keeps the predicate fast even on a large table. + @@index([visibility, ownerId]) } // ── Groups ── @@ -495,6 +523,14 @@ model Agent { status LlmStatus @default(active) inactiveSince DateTime? ownerId String + // v7: per-user RBAC scoping. Mirrors `Llm.visibility` semantics — + // 'public' (default, today's behavior) lets anyone with the resource + // grant see the agent; 'private' restricts to owner + explicit + // name-scoped RBAC bindings. mcplocal-published virtual agents + // default to 'private' on register so a workstation-published persona + // isn't broadcast to the whole org until shared explicitly. Existing + // rows backfill to 'public' so pre-v7 setups keep working unchanged. + visibility String @default("public") version Int @default(1) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -514,6 +550,7 @@ model Agent { @@index([defaultPersonalityId]) @@index([kind, status]) @@index([providerSessionId]) + @@index([visibility, ownerId]) } // ── Personalities (named overlay bundles of prompts on top of an Agent) ── diff --git a/src/mcpd/src/repositories/agent.repository.ts b/src/mcpd/src/repositories/agent.repository.ts index 9f83541..271ef25 100644 --- a/src/mcpd/src/repositories/agent.repository.ts +++ b/src/mcpd/src/repositories/agent.repository.ts @@ -11,6 +11,8 @@ export interface CreateAgentRepoInput { defaultParams?: Record; extras?: Record; ownerId: string; + // v7: optional visibility scope (default 'public' if omitted). + visibility?: 'public' | 'private'; // Virtual-agent lifecycle (omit for kind=public). kind?: LlmKind; providerSessionId?: string | null; @@ -28,6 +30,9 @@ export interface UpdateAgentRepoInput { proxyModelName?: string | null; defaultParams?: Record; extras?: Record; + // v7: visibility is mutable (operator can flip a private virtual to + // public for org-wide sharing without re-creating). + visibility?: 'public' | 'private'; // Virtual-agent lifecycle. AgentService is the only public writer; the // VirtualAgentService methods (Stage 2) bypass the public CRUD path. kind?: LlmKind; @@ -87,6 +92,7 @@ export class AgentRepository implements IAgentRepository { defaultParams: (data.defaultParams ?? {}) as Prisma.InputJsonValue, extras: (data.extras ?? {}) as Prisma.InputJsonValue, ownerId: data.ownerId, + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.kind !== undefined ? { kind: data.kind } : {}), ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}), ...(data.status !== undefined ? { status: data.status } : {}), @@ -122,6 +128,7 @@ export class AgentRepository implements IAgentRepository { if (data.extras !== undefined) { updateData.extras = data.extras as Prisma.InputJsonValue; } + if (data.visibility !== undefined) updateData.visibility = data.visibility; if (data.kind !== undefined) updateData.kind = data.kind; if (data.providerSessionId !== undefined) updateData.providerSessionId = data.providerSessionId; if (data.status !== undefined) updateData.status = data.status; diff --git a/src/mcpd/src/repositories/llm.repository.ts b/src/mcpd/src/repositories/llm.repository.ts index 8942e19..b10bd64 100644 --- a/src/mcpd/src/repositories/llm.repository.ts +++ b/src/mcpd/src/repositories/llm.repository.ts @@ -12,6 +12,11 @@ export interface CreateLlmInput { extraConfig?: Record; // v4: optional pool key. NULL = "pool of 1" (effective key falls back to `name`). poolName?: string | null; + // v7: per-user RBAC scoping. ownerId is set by the service layer to + // the authenticated caller's User.id; visibility defaults to 'public' + // and gets flipped to 'private' for mcplocal-published virtuals. + ownerId?: string | null; + visibility?: 'public' | 'private'; // Virtual-provider lifecycle (omit for kind=public). kind?: LlmKind; providerSessionId?: string | null; @@ -30,6 +35,10 @@ export interface UpdateLlmInput { apiKeySecretKey?: string | null; extraConfig?: Record; poolName?: string | null; + // v7: ownerId immutable at update time (use a separate transfer flow if + // ever needed). Visibility is mutable so an operator can flip a + // virtual Llm to public for org-wide sharing without re-creating it. + visibility?: 'public' | 'private'; // Virtual-provider lifecycle. VirtualLlmService is the only writer for // these in v1; the public CRUD path leaves them undefined. kind?: LlmKind; @@ -108,6 +117,8 @@ export class LlmRepository implements ILlmRepository { apiKeySecretKey: data.apiKeySecretKey ?? null, extraConfig: (data.extraConfig ?? {}) as Prisma.InputJsonValue, ...(data.poolName !== undefined ? { poolName: data.poolName } : {}), + ...(data.ownerId !== undefined ? { ownerId: data.ownerId } : {}), + ...(data.visibility !== undefined ? { visibility: data.visibility } : {}), ...(data.kind !== undefined ? { kind: data.kind } : {}), ...(data.providerSessionId !== undefined ? { providerSessionId: data.providerSessionId } : {}), ...(data.status !== undefined ? { status: data.status } : {}), @@ -132,6 +143,7 @@ export class LlmRepository implements ILlmRepository { if (data.apiKeySecretKey !== undefined) updateData.apiKeySecretKey = data.apiKeySecretKey; if (data.extraConfig !== undefined) updateData.extraConfig = data.extraConfig as Prisma.InputJsonValue; if (data.poolName !== undefined) updateData.poolName = data.poolName; + if (data.visibility !== undefined) updateData.visibility = data.visibility; if (data.kind !== undefined) updateData.kind = data.kind; if (data.providerSessionId !== undefined) updateData.providerSessionId = data.providerSessionId; if (data.status !== undefined) updateData.status = data.status; diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 601d0dc..4875766 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -21,6 +21,29 @@ import { } from '../validation/agent.schema.js'; import { NotFoundError, ConflictError } from './mcp-server.service.js'; +/** + * v7: visibility scope for the current request, mirrors LlmService.Viewer. + * Same semantics — null = no filter; wildcard = full grant; else + * filter to public-or-owned-or-name-scoped. + */ +export interface AgentViewer { + userId: string; + wildcard: boolean; + allowedNames: Set; +} + +export function isAgentVisibleTo( + row: { name: string; ownerId: string; visibility: string }, + viewer: AgentViewer | null, +): boolean { + if (viewer === null) return true; + if (viewer.wildcard) return true; + if (row.visibility !== 'private') return true; + if (row.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(row.name)) return true; + return false; +} + /** Shape returned by the API layer — embeds llm + project metadata. */ export interface AgentView { id: string; @@ -39,6 +62,8 @@ export interface AgentView { lastHeartbeatAt: Date | null; inactiveSince: Date | null; ownerId: string; + /** v7: per-user RBAC scoping. mcplocal-published virtuals default to 'private'. */ + visibility: 'public' | 'private'; version: number; createdAt: Date; updatedAt: Date; @@ -63,26 +88,30 @@ export class AgentService { private readonly personalities?: IPersonalityRepository, ) {} - async list(): Promise { + async list(viewer: AgentViewer | null = null): Promise { const rows = await this.repo.findAll(); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async listByProject(projectName: string): Promise { + async listByProject(projectName: string, viewer: AgentViewer | null = null): Promise { const project = await this.projects.resolveAndGet(projectName); const rows = await this.repo.findByProjectId(project.id); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isAgentVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async getById(id: string): Promise { + async getById(id: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Agent not found: ${id}`); + if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${id}`); return this.toView(row); } - async getByName(name: string): Promise { + async getByName(name: string, viewer: AgentViewer | null = null): Promise { const row = await this.repo.findByName(name); if (row === null) throw new NotFoundError(`Agent not found: ${name}`); + if (!isAgentVisibleTo(row, viewer)) throw new NotFoundError(`Agent not found: ${name}`); return this.toView(row); } @@ -200,6 +229,7 @@ export class AgentService { lastHeartbeatAt: row.lastHeartbeatAt, inactiveSince: row.inactiveSince, ownerId: row.ownerId, + visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private', version: row.version, createdAt: row.createdAt, updatedAt: row.updatedAt, diff --git a/src/mcpd/src/services/llm.service.ts b/src/mcpd/src/services/llm.service.ts index af37176..1ee2149 100644 --- a/src/mcpd/src/services/llm.service.ts +++ b/src/mcpd/src/services/llm.service.ts @@ -57,6 +57,17 @@ export interface LlmView { * expands at request time. */ poolName: string | null; + /** + * v7: per-user RBAC scoping. NULL `ownerId` on legacy rows means + * "no recorded owner"; treated as public for visibility checks. + */ + ownerId: string | null; + /** + * v7: visibility scope. 'public' = anyone with the resource grant; + * 'private' = owner + explicit name-scoped RBAC bindings only. + * mcplocal virtuals default to 'private' on register. + */ + visibility: 'public' | 'private'; // Virtual-provider lifecycle (kind defaults to 'public' for legacy rows). kind: 'public' | 'virtual'; status: 'active' | 'inactive' | 'hibernating'; @@ -77,6 +88,49 @@ export function effectivePoolName(row: { name: string; poolName: string | null } return row.poolName !== null && row.poolName !== '' ? row.poolName : row.name; } +/** + * v7: visibility scope for the current request. The route layer + * computes this from `request.userId` + `RbacService.getAllowedScope` + * and passes it down to LlmService for filtering. When `wildcard` is + * true (admin / resource-wide `view:llms`), no filter applies — every + * row is visible regardless of owner / private flag. When false, the + * filter is `visibility='public' OR ownerId=userId OR name in allowedNames`. + * + * `null` Viewer means "skip the v7 filter entirely" — used by internal + * callers (cron sweeps, audit collectors) and tests that don't have a + * request context. + */ +export interface Viewer { + userId: string; + /** True when the caller has resource-wide `view:llms` (or admin). */ + wildcard: boolean; + /** Name-scoped grants the caller holds (e.g. `view:llms:vllm-alice`). */ + allowedNames: Set; +} + +/** + * v7: shared predicate. A row is visible to the viewer when: + * - visibility is public AND row passes RBAC layer above (caller already has resource grant), OR + * - viewer is null (internal call, no filter), OR + * - viewer has wildcard grant, OR + * - viewer is the owner, OR + * - the row's name is in viewer.allowedNames (name-scoped grant). + * + * Pure function so service tests can exercise it directly without a + * full mock RbacService. + */ +export function isLlmVisibleTo( + row: { name: string; ownerId: string | null; visibility: string }, + viewer: Viewer | null, +): boolean { + if (viewer === null) return true; + if (viewer.wildcard) return true; + if (row.visibility !== 'private') return true; + if (row.ownerId !== null && row.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(row.name)) return true; + return false; +} + export class LlmService { constructor( private readonly repo: ILlmRepository, @@ -84,20 +138,25 @@ export class LlmService { private readonly verifyDeps: LlmServiceDeps = {}, ) {} - async list(): Promise { + async list(viewer: Viewer | null = null): Promise { const rows = await this.repo.findAll(); - return Promise.all(rows.map((r) => this.toView(r))); + const visible = rows.filter((r) => isLlmVisibleTo(r, viewer)); + return Promise.all(visible.map((r) => this.toView(r))); } - async getById(id: string): Promise { + async getById(id: string, viewer: Viewer | null = null): Promise { const row = await this.repo.findById(id); if (row === null) throw new NotFoundError(`Llm not found: ${id}`); + // 404 (not 403) on a foreign-private row prevents id-enumeration — + // identical shape to the chat-thread + inference-task routes. + if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${id}`); return this.toView(row); } - async getByName(name: string): Promise { + async getByName(name: string, viewer: Viewer | null = null): Promise { const row = await this.repo.findByName(name); if (row === null) throw new NotFoundError(`Llm not found: ${name}`); + if (!isLlmVisibleTo(row, viewer)) throw new NotFoundError(`Llm not found: ${name}`); return this.toView(row); } @@ -326,6 +385,8 @@ export class LlmService { apiKeyRef, extraConfig: row.extraConfig as Record, poolName: row.poolName, + ownerId: row.ownerId, + visibility: (row.visibility === 'private' ? 'private' : 'public') as 'public' | 'private', kind: row.kind, status: row.status, lastHeartbeatAt: row.lastHeartbeatAt, diff --git a/src/mcpd/tests/visibility-filter.test.ts b/src/mcpd/tests/visibility-filter.test.ts new file mode 100644 index 0000000..8758e9b --- /dev/null +++ b/src/mcpd/tests/visibility-filter.test.ts @@ -0,0 +1,105 @@ +/** + * v7 Stage 1 — pure-function tests for the visibility predicate. + * Lives separately from the service-level tests because the predicate + * is the single source of truth for "can user X see this row" and is + * called from both LlmService.list and AgentService.list. We exercise + * every branch of the decision tree to lock the semantics in. + */ +import { describe, it, expect } from 'vitest'; +import { isLlmVisibleTo, type Viewer } from '../src/services/llm.service.js'; +import { isAgentVisibleTo, type AgentViewer } from '../src/services/agent.service.js'; + +const llmRow = (overrides: { name?: string; ownerId?: string | null; visibility?: string } = {}): { name: string; ownerId: string | null; visibility: string } => ({ + name: 'vllm-alice', + ownerId: 'alice', + visibility: 'private', + ...overrides, +}); + +const agentRow = (overrides: { name?: string; ownerId?: string; visibility?: string } = {}): { name: string; ownerId: string; visibility: string } => ({ + name: 'reviewer', + ownerId: 'alice', + visibility: 'private', + ...overrides, +}); + +describe('isLlmVisibleTo (v7)', () => { + it('null viewer skips the filter — internal callers see everything', () => { + // Cron sweeps, audit collectors, and tests without a request + // context get a null viewer. The visibility filter is then a + // no-op, which matches the pre-v7 behavior of those code paths. + expect(isLlmVisibleTo(llmRow(), null)).toBe(true); + }); + + it('public rows are visible to anyone with the resource grant', () => { + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'public' }), v)).toBe(true); + }); + + it('wildcard viewer (admin) sees private rows owned by others', () => { + const v: Viewer = { userId: 'admin', wildcard: true, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('owner sees their own private row', () => { + const v: Viewer = { userId: 'alice', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('non-owner without name-scoped grant cannot see a private row', () => { + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false); + }); + + it('non-owner WITH name-scoped grant can see a private row', () => { + // alice published vllm-alice as private; alice ran + // `mcpctl create rbac binding view:llms:vllm-alice --user bob`, + // so bob now sees the row in his list output. + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['vllm-alice']) }; + expect(isLlmVisibleTo(llmRow({ name: 'vllm-alice', visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('treats null ownerId as no-owner (legacy rows pre-v7 backfill stay visible if public)', () => { + // The migration sets visibility='public' for legacy rows, so they + // pass the public-visibility check before the ownerId branch is + // ever reached. A row with NULL ownerId AND visibility='private' + // is unreachable via normal flows, but we still want the predicate + // to behave: no owner + bob viewing = not visible. + const v: Viewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isLlmVisibleTo(llmRow({ ownerId: null, visibility: 'private' }), v)).toBe(false); + }); +}); + +describe('isAgentVisibleTo (v7)', () => { + // Same shape as Llm; agents always have a non-null ownerId because + // `Agent.ownerId` is required, so we don't need the legacy-null + // branch test. + it('null viewer = visible (internal calls bypass filter)', () => { + expect(isAgentVisibleTo(agentRow(), null)).toBe(true); + }); + + it('public agents visible to anyone with resource grant', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'public' }), v)).toBe(true); + }); + + it('owner sees own private agent', () => { + const v: AgentViewer = { userId: 'alice', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('non-owner without grant blocked from private agent', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(false); + }); + + it('non-owner WITH name-scoped grant can see private agent', () => { + const v: AgentViewer = { userId: 'bob', wildcard: false, allowedNames: new Set(['reviewer']) }; + expect(isAgentVisibleTo(agentRow({ name: 'reviewer', visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); + + it('wildcard viewer sees private agent owned by another user', () => { + const v: AgentViewer = { userId: 'admin', wildcard: true, allowedNames: new Set() }; + expect(isAgentVisibleTo(agentRow({ visibility: 'private', ownerId: 'alice' }), v)).toBe(true); + }); +}); -- 2.49.1 From 2c98a213239636c2b4b83cc28bd75107e35ac14f Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 29 Apr 2026 01:03:58 +0100 Subject: [PATCH 02/18] feat(mcpd+cli+mcplocal): wire visibility filter through routes, CLI, registrar (v7 Stage 2) Stage 1 added the schema + service predicate. This stage threads the filter through every surface that lists or fetches Llms/Agents: - mcpd routes: viewerFromRequest helper builds a Viewer from the request's RBAC scope. List endpoints rely on the existing preSerialization hook (now two-phase: name-scope first, visibility second). get-by-id/get-by-name routes pass the viewer to the service which 404s on hidden rows. - RBAC: AllowedScope gains `isAdmin` to distinguish a `*` cross-resource grant (admins skip visibility) from a plain `view:llms` grant (wildcard for RBAC, but visibility still applies). FastifyRequest augmentation updated. - VirtualLlmService.register accepts ownerId and stamps it on freshly created virtual rows; defaults visibility to 'private' on first create, leaves existing rows untouched on sticky reconnect. - AgentService.registerVirtualAgents mirrors the same defaults. - mcplocal: LlmProviderFileEntry / AgentFileEntry / RegistrarPublishedX carry visibility through to the register payload (default 'private'). - CLI: VISIBILITY column on `mcpctl get llm` and `mcpctl get agent`, `--visibility` flag on `mcpctl create llm` / `create agent`. YAML round-trip works because visibility passes through stripInternalFields unchanged (ownerId is already stripped). Completions regenerated. Tests: mcpd 908/908, mcplocal 731/731, cli 437/437. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 2 + src/cli/src/commands/create.ts | 14 ++++++ src/cli/src/commands/get.ts | 8 ++++ src/mcpd/src/main.ts | 46 +++++++++++++++--- src/mcpd/src/middleware/auth.ts | 9 +++- src/mcpd/src/routes/agents.ts | 30 ++++++++---- src/mcpd/src/routes/llms.ts | 50 ++++++++++++++++---- src/mcpd/src/routes/virtual-llms.ts | 15 ++++++ src/mcpd/src/services/agent.service.ts | 12 +++++ src/mcpd/src/services/rbac.service.ts | 30 ++++++++++-- src/mcpd/src/services/virtual-llm.service.ts | 21 +++++++- src/mcpd/tests/virtual-llm-routes.test.ts | 1 + src/mcplocal/src/http/config.ts | 10 ++++ src/mcplocal/src/main.ts | 5 ++ src/mcplocal/src/providers/registrar.ts | 17 +++++++ 16 files changed, 241 insertions(+), 33 deletions(-) diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index eb27698..acc6c38 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -185,10 +185,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "--data --force -h --help" -- "$cur")) ;; llm) - COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --pool-name --force --skip-auth-check -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--type --model --url --tier --description --api-key-ref --extra --pool-name --visibility --force --skip-auth-check -h --help" -- "$cur")) ;; agent) - COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --force -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "--llm --project --description --system-prompt --system-prompt-file --proxy-model --default-temperature --default-top-p --default-top-k --default-max-tokens --default-seed --default-stop --default-extra --default-params-file --visibility --force -h --help" -- "$cur")) ;; secretbackend) COMPREPLY=($(compgen -W "--type --description --default --url --namespace --mount --path-prefix --auth --token-secret --role --auth-mount --sa-token-path --config --wizard --setup-token --policy-name --token-role --no-promote-default --force -h --help" -- "$cur")) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index e4162a2..80c131f 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -335,6 +335,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l description -d 'Des complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l api-key-ref -d 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l extra -d 'Extra config key=value (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l pool-name -d 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l visibility -d 'Visibility scope: public (everyone) or private (only owner + name-grants)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l force -d 'Update if already exists' complete -c mcpctl -n "__mcpctl_subcmd_active create llm" -l skip-auth-check -d 'Skip the upstream auth probe (for offline registration before infra exists)' @@ -353,6 +354,7 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-seed -d ' complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-stop -d 'Default stop sequence (repeat for multiple)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-extra -d 'Default provider-specific knob k=v (repeat)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l default-params-file -d 'Read defaultParams from a JSON file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l visibility -d 'Visibility scope: public (everyone) or private (only owner + name-grants)' -x complete -c mcpctl -n "__mcpctl_subcmd_active create agent" -l force -d 'Update if already exists' # create secretbackend options diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index d0f4d69..95a35d3 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -264,6 +264,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--api-key-ref ', 'API key reference in SECRET/KEY form (e.g. anthropic-key/token)') .option('--extra ', 'Extra config key=value (repeat)', collect, []) .option('--pool-name ', 'Stack with other Llms sharing this pool name; agents pinned to any member dispatch across the pool') + .option('--visibility ', 'Visibility scope: public (everyone) or private (only owner + name-grants)', 'public') .option('--force', 'Update if already exists') .option('--skip-auth-check', 'Skip the upstream auth probe (for offline registration before infra exists)') .action(async (name: string, opts) => { @@ -276,6 +277,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { if (opts.url) body.url = opts.url; if (opts.description !== undefined) body.description = opts.description; if (opts.poolName !== undefined) body.poolName = opts.poolName; + if (opts.visibility !== undefined) { + if (opts.visibility !== 'public' && opts.visibility !== 'private') { + throw new Error(`Invalid --visibility '${opts.visibility as string}'. Expected 'public' or 'private'`); + } + body.visibility = opts.visibility; + } if (opts.apiKeyRef) { const slashIdx = (opts.apiKeyRef as string).indexOf('/'); if (slashIdx < 1) throw new Error(`Invalid --api-key-ref '${opts.apiKeyRef as string}'. Expected SECRET_NAME/KEY_NAME`); @@ -333,6 +340,7 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { .option('--default-stop ', 'Default stop sequence (repeat for multiple)', collect, []) .option('--default-extra ', 'Default provider-specific knob k=v (repeat)', collect, []) .option('--default-params-file ', 'Read defaultParams from a JSON file') + .option('--visibility ', 'Visibility scope: public (everyone) or private (only owner + name-grants)', 'public') .option('--force', 'Update if already exists') .action(async (name: string, opts) => { const body: Record = { @@ -341,6 +349,12 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { }; if (opts.project) body.project = { name: opts.project }; if (opts.description !== undefined) body.description = opts.description; + if (opts.visibility !== undefined) { + if (opts.visibility !== 'public' && opts.visibility !== 'private') { + throw new Error(`Invalid --visibility '${opts.visibility as string}'. Expected 'public' or 'private'`); + } + body.visibility = opts.visibility; + } let systemPrompt = opts.systemPrompt as string | undefined; if (systemPrompt === undefined && opts.systemPromptFile !== undefined) { diff --git a/src/cli/src/commands/get.ts b/src/cli/src/commands/get.ts index 445bc6d..5c1ca16 100644 --- a/src/cli/src/commands/get.ts +++ b/src/cli/src/commands/get.ts @@ -138,6 +138,10 @@ interface LlmRow { status?: 'active' | 'inactive' | 'hibernating'; // v4: explicit pool key. NULL = solo Llm (effective pool = its own name). poolName?: string | null; + // v7: visibility scope. Legacy public rows omit it; mcpd defaults missing + // values to 'public' on serialization. + visibility?: 'public' | 'private'; + ownerId?: string | null; } // v4: POOL column placed right after NAME so an operator can't miss @@ -148,6 +152,7 @@ const llmColumns: Column[] = [ { header: 'POOL', key: (r) => (r.poolName !== null && r.poolName !== undefined && r.poolName !== '') ? r.poolName : '-', width: 18 }, { header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 }, { header: 'STATUS', key: (r) => r.status ?? 'active', width: 12 }, + { header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 }, { header: 'TYPE', key: 'type', width: 12 }, { header: 'MODEL', key: 'model', width: 28 }, { header: 'TIER', key: 'tier', width: 8 }, @@ -214,12 +219,15 @@ interface AgentRow { // AgentService as the publishing mcplocal heartbeats and disconnects. kind?: 'public' | 'virtual'; status?: 'active' | 'inactive'; + // v7: visibility — same semantics as Llm. Public legacy agents omit it. + visibility?: 'public' | 'private'; } const agentColumns: Column[] = [ { header: 'NAME', key: 'name' }, { header: 'KIND', key: (r) => r.kind ?? 'public', width: 8 }, { header: 'STATUS', key: (r) => r.status ?? 'active', width: 10 }, + { header: 'VISIBILITY', key: (r) => r.visibility ?? 'public', width: 11 }, { header: 'LLM', key: (r) => r.llm.name, width: 24 }, { header: 'PROJECT', key: (r) => r.project?.name ?? '-', width: 20 }, { header: 'DESCRIPTION', key: (r) => truncate(r.description, 50) || '-', width: 50 }, diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index d53828f..8269e7c 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -747,14 +747,48 @@ async function main(): Promise { registerGitBackupRoutes(app, gitBackup); // ── RBAC list filtering hook ── - // Filters array responses to only include resources the user is allowed to see. + // + // Two filters compose here, in order: + // + // 1. RBAC name-scope (existing): when a caller has only name-scoped + // grants (no resource-wide), only items whose name is in their + // grants set pass through. wildcard=true skips this step. + // + // 2. v7 visibility filter (new): private rows are hidden from + // callers who aren't the owner and don't have a name-scoped + // grant. Skipped for `*`-resource admins (isAdmin=true) so + // org-wide audit/list operations still work. Importantly, this + // runs even when wildcard=true — that's how a regular `view:llms` + // grant stops broadcasting private virtuals across the org while + // still letting `*`-grant admins see everything. + // + // Items without a `visibility` field (today: every resource other + // than Llm and Agent) pass through this step unchanged. app.addHook('preSerialization', async (request, _reply, payload) => { - if (!request.rbacScope || request.rbacScope.wildcard) return payload; + if (!request.rbacScope) return payload; if (!Array.isArray(payload)) return payload; - return (payload as Array>).filter((item) => { - const name = item['name']; - return typeof name === 'string' && request.rbacScope!.names.has(name); - }); + let items = payload as Array>; + // Step 1: RBAC name-scope. + if (!request.rbacScope.wildcard) { + items = items.filter((item) => { + const name = item['name']; + return typeof name === 'string' && request.rbacScope!.names.has(name); + }); + } + // Step 2: visibility (only meaningful when the resource carries it). + if (!request.rbacScope.isAdmin) { + const userId = request.userId; + items = items.filter((item) => { + const visibility = item['visibility']; + if (visibility !== 'private') return true; + const ownerId = item['ownerId']; + if (typeof ownerId === 'string' && ownerId === userId) return true; + const name = item['name']; + if (typeof name === 'string' && request.rbacScope!.names.has(name)) return true; + return false; + }); + } + return items; }); // Web UI: served from /ui (static SPA bundle). Falls through to API diff --git a/src/mcpd/src/middleware/auth.ts b/src/mcpd/src/middleware/auth.ts index 0bc3a3c..741ad66 100644 --- a/src/mcpd/src/middleware/auth.ts +++ b/src/mcpd/src/middleware/auth.ts @@ -33,7 +33,14 @@ export interface AuthDeps { declare module 'fastify' { interface FastifyRequest { userId?: string; - rbacScope?: { wildcard: boolean; names: Set }; + /** + * v7: extended with `isAdmin` to distinguish `*` (cross-resource + * admin) grants from plain `view:llms` resource-wide grants. The + * preSerialization filter and the route-level Viewer use this to + * decide whether to skip the visibility filter (admins bypass; + * regular wildcard does not). + */ + rbacScope?: { wildcard: boolean; isAdmin: boolean; names: Set }; /** Set by the auth hook when the caller authenticated via a McpToken bearer (prefix `mcpctl_pat_`). */ mcpToken?: McpTokenPrincipal; } diff --git a/src/mcpd/src/routes/agents.ts b/src/mcpd/src/routes/agents.ts index 5e3e633..d2fbf09 100644 --- a/src/mcpd/src/routes/agents.ts +++ b/src/mcpd/src/routes/agents.ts @@ -6,21 +6,33 @@ * — the resource is `agents`. The chat endpoints live in `agent-chat.ts` and * map to `run:agents:`. */ -import type { FastifyInstance } from 'fastify'; -import type { AgentService } from '../services/agent.service.js'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { AgentService, AgentViewer } from '../services/agent.service.js'; import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; +/** v7: thread the request's RBAC scope into the service so foreign-private rows 404. */ +function viewerFromRequest(request: FastifyRequest): AgentViewer | null { + if (request.userId === undefined || request.rbacScope === undefined) return null; + return { + userId: request.userId, + wildcard: request.rbacScope.isAdmin, + allowedNames: request.rbacScope.names, + }; +} + export function registerAgentRoutes( app: FastifyInstance, service: AgentService, ): void { app.get('/api/v1/agents', async () => { + // List filter is applied by the preSerialization hook (visibility + + // RBAC name-scope); service stays viewer-blind for internal callers. return service.list(); }); app.get<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => { try { - return await getByIdOrName(service, request.params.id); + return await getByIdOrName(service, request.params.id, viewerFromRequest(request)); } catch (err) { if (err instanceof NotFoundError) { reply.code(404); @@ -51,7 +63,7 @@ export function registerAgentRoutes( app.put<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => { try { - const target = await getByIdOrName(service, request.params.id); + const target = await getByIdOrName(service, request.params.id, viewerFromRequest(request)); return await service.update(target.id, request.body); } catch (err) { if (err instanceof NotFoundError) { @@ -64,7 +76,7 @@ export function registerAgentRoutes( app.delete<{ Params: { id: string } }>('/api/v1/agents/:id', async (request, reply) => { try { - const target = await getByIdOrName(service, request.params.id); + const target = await getByIdOrName(service, request.params.id, viewerFromRequest(request)); await service.delete(target.id); reply.code(204); return null; @@ -84,7 +96,7 @@ export function registerAgentRoutes( '/api/v1/projects/:projectName/agents', async (request, reply) => { try { - return await service.listByProject(request.params.projectName); + return await service.listByProject(request.params.projectName, viewerFromRequest(request)); } catch (err) { if (err instanceof NotFoundError) { reply.code(404); @@ -98,9 +110,9 @@ export function registerAgentRoutes( const CUID_RE = /^c[a-z0-9]{24}/i; -async function getByIdOrName(service: AgentService, idOrName: string) { +async function getByIdOrName(service: AgentService, idOrName: string, viewer: AgentViewer | null = null) { if (CUID_RE.test(idOrName)) { - return service.getById(idOrName); + return service.getById(idOrName, viewer); } - return service.getByName(idOrName); + return service.getByName(idOrName, viewer); } diff --git a/src/mcpd/src/routes/llms.ts b/src/mcpd/src/routes/llms.ts index b153a9a..740e8d1 100644 --- a/src/mcpd/src/routes/llms.ts +++ b/src/mcpd/src/routes/llms.ts @@ -1,13 +1,32 @@ -import type { FastifyInstance } from 'fastify'; -import type { LlmService, LlmView } from '../services/llm.service.js'; +import type { FastifyInstance, FastifyRequest } from 'fastify'; +import type { LlmService, LlmView, Viewer } from '../services/llm.service.js'; import { LlmAuthVerificationError, effectivePoolName } from '../services/llm.service.js'; import { NotFoundError, ConflictError } from '../services/mcp-server.service.js'; +/** + * v7: build a Viewer from the request's RBAC scope so the service + * applies the visibility filter consistently. The list endpoint relies + * on the preSerialization hook for the same logic; for get-by-name/id + * the service does the filter itself and 404s on a hidden row. + */ +function viewerFromRequest(request: FastifyRequest): Viewer | null { + if (request.userId === undefined || request.rbacScope === undefined) return null; + return { + userId: request.userId, + wildcard: request.rbacScope.isAdmin, // only admins skip the visibility filter + allowedNames: request.rbacScope.names, + }; +} + export function registerLlmRoutes( app: FastifyInstance, service: LlmService, ): void { app.get('/api/v1/llms', async () => { + // List goes through the preSerialization hook which applies both + // RBAC name-scope and v7 visibility filter — service stays + // viewer-blind here so internal callers (audit, sweeps) keep + // working without a request context. return service.list(); }); @@ -16,7 +35,7 @@ export function registerLlmRoutes( // hands over the user-facing name to avoid an extra round-trip). app.get<{ Params: { id: string } }>('/api/v1/llms/:id', async (request, reply) => { try { - return await getByIdOrName(service, request.params.id); + return await getByIdOrName(service, request.params.id, viewerFromRequest(request)); } catch (err) { if (err instanceof NotFoundError) { reply.code(404); @@ -96,8 +115,20 @@ export function registerLlmRoutes( // size + activeCount. app.get<{ Params: { name: string } }>('/api/v1/llms/:name/members', async (request, reply) => { try { - const anchor = await getByIdOrName(service, request.params.name); - const members = await service.listPoolMembers(effectivePoolName(anchor)); + const viewer = viewerFromRequest(request); + const anchor = await getByIdOrName(service, request.params.name, viewer); + const allMembers = await service.listPoolMembers(effectivePoolName(anchor)); + // v7: filter pool members by visibility too — without this, a + // pool with private members would leak their names through the + // /members endpoint even though the list endpoint hides them. + const members = viewer === null + ? allMembers + : allMembers.filter((m) => { + if (m.visibility !== 'private') return true; + if (m.ownerId !== null && m.ownerId === viewer.userId) return true; + if (viewer.allowedNames.has(m.name)) return true; + return viewer.wildcard; + }); return { poolName: effectivePoolName(anchor), explicitPoolName: anchor.poolName, @@ -131,11 +162,12 @@ const CUID_RE = /^c[a-z0-9]{24}/i; /** * Look up by CUID first; if the input doesn't look like one, fall back to * findByName. Lets the same URL serve both `mcpctl describe llm ` and - * the FailoverRouter's name-based RBAC check. + * the FailoverRouter's name-based RBAC check. v7: the optional viewer + * threads through to the service so foreign-private rows 404 cleanly. */ -async function getByIdOrName(service: LlmService, idOrName: string) { +async function getByIdOrName(service: LlmService, idOrName: string, viewer: Viewer | null = null) { if (CUID_RE.test(idOrName)) { - return service.getById(idOrName); + return service.getById(idOrName, viewer); } - return service.getByName(idOrName); + return service.getByName(idOrName, viewer); } diff --git a/src/mcpd/src/routes/virtual-llms.ts b/src/mcpd/src/routes/virtual-llms.ts index fbde2ba..be86d1f 100644 --- a/src/mcpd/src/routes/virtual-llms.ts +++ b/src/mcpd/src/routes/virtual-llms.ts @@ -45,9 +45,14 @@ export function registerVirtualLlmRoutes( const agentsInput = Array.isArray(body.agents) ? body.agents : null; try { + // v7: ownerId from the authenticated request lands on every + // newly-created virtual Llm row (sticky reconnects update the + // existing row's ownerId too — same publisher, same session). + const ownerId = request.userId ?? 'system'; const result = await service.register({ providerSessionId: body.providerSessionId ?? null, providers: providers.map(coerceProviderInput), + ownerId, }); // v3: atomically publish virtual agents tied to the same session. // If the caller didn't include an agents array, skip silently. @@ -189,6 +194,10 @@ function coerceAgentInput(raw: unknown): VirtualAgentInput { if (o['extras'] !== null && typeof o['extras'] === 'object') { out.extras = o['extras'] as Record; } + // v7: optional visibility scope (defaults to 'private' on first publish). + if (o['visibility'] === 'public' || o['visibility'] === 'private') { + out.visibility = o['visibility']; + } return out; } @@ -202,6 +211,7 @@ function coerceProviderInput(raw: unknown): { extraConfig?: Record; initialStatus?: 'active' | 'hibernating'; poolName?: string; + visibility?: 'public' | 'private'; } { if (raw === null || typeof raw !== 'object') { throw Object.assign(new Error('provider entry must be an object'), { statusCode: 400 }); @@ -234,5 +244,10 @@ function coerceProviderInput(raw: unknown): { if (typeof o['poolName'] === 'string' && /^[a-z0-9-]+$/.test(o['poolName']) && o['poolName'].length >= 1 && o['poolName'].length <= 100) { out.poolName = o['poolName']; } + // v7: visibility. Only 'public' or 'private'; unknown values fall + // through to the service default ('private' for virtuals). + if (o['visibility'] === 'public' || o['visibility'] === 'private') { + out.visibility = o['visibility']; + } return out; } diff --git a/src/mcpd/src/services/agent.service.ts b/src/mcpd/src/services/agent.service.ts index 4875766..440d4d2 100644 --- a/src/mcpd/src/services/agent.service.ts +++ b/src/mcpd/src/services/agent.service.ts @@ -78,6 +78,12 @@ export interface VirtualAgentInput { project?: string; defaultParams?: Record; extras?: Record; + /** + * v7: per-user RBAC scoping. When omitted, virtual agents default to + * 'private' on register — same shape as virtual Llms. The publisher + * can override per-agent in mcplocal config. + */ + visibility?: 'public' | 'private'; } export class AgentService { @@ -292,6 +298,10 @@ export class AgentService { projectId, ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}), ...(a.extras !== undefined ? { extras: a.extras } : {}), + // v7: only update visibility on sticky reconnect when the + // publisher explicitly sent it — operators may have flipped + // a virtual agent to public manually via `mcpctl edit agent`. + ...(a.visibility !== undefined ? { visibility: a.visibility } : {}), kind: 'virtual', providerSessionId: sessionId, status: 'active', @@ -309,6 +319,8 @@ export class AgentService { projectId, ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}), ...(a.extras !== undefined ? { extras: a.extras } : {}), + // v7: virtual agents default to private on first publish. + visibility: a.visibility ?? 'private', kind: 'virtual', providerSessionId: sessionId, status: 'active', diff --git a/src/mcpd/src/services/rbac.service.ts b/src/mcpd/src/services/rbac.service.ts index ddc7fe2..db76359 100644 --- a/src/mcpd/src/services/rbac.service.ts +++ b/src/mcpd/src/services/rbac.service.ts @@ -24,7 +24,17 @@ export interface OperationPermission { export type Permission = ResourcePermission | OperationPermission; export interface AllowedScope { + /** True iff the user has a resource-wide grant for this resource (no name constraint). */ wildcard: boolean; + /** + * v7: true iff the user has `*` (cross-resource admin) grant. The + * visibility filter (per-user RBAC scoping for virtual Llms/Agents) + * skips itself when this is set — admins see private rows from any + * owner, just like before v7. Plain `view:llms` resource-wide grants + * set `wildcard=true` but `isAdmin=false`, so private rows from other + * users are still hidden in the list view. + */ + isAdmin: boolean; names: Set; } @@ -97,6 +107,8 @@ export class RbacService { const permissions = await this.getPermissions(userId, serviceAccountName, mcpTokenSha); const normalized = normalizeResource(resource); const names = new Set(); + let wildcard = false; + let isAdmin = false; for (const perm of permissions) { if (!('resource' in perm)) continue; @@ -105,12 +117,22 @@ export class RbacService { if (!actions.includes(action)) continue; const permResource = normalizeResource(perm.resource); if (permResource !== '*' && permResource !== normalized) continue; - // Unscoped binding → wildcard access to this resource - if (perm.name === undefined) return { wildcard: true, names: new Set() }; - names.add(perm.name); + // Unscoped binding → wildcard access to this resource. v7: also + // record whether the binding came from a `*` (cross-resource + // admin) grant — that's the only one that bypasses the + // visibility filter for private rows from other owners. + if (perm.name === undefined) { + wildcard = true; + if (permResource === '*') isAdmin = true; + // Don't return early — keep scanning so isAdmin can flip true + // even if a more-specific binding matched first. + } else { + names.add(perm.name); + } } - return { wildcard: false, names }; + if (wildcard) return { wildcard: true, isAdmin, names: new Set() }; + return { wildcard: false, isAdmin: false, names }; } /** diff --git a/src/mcpd/src/services/virtual-llm.service.ts b/src/mcpd/src/services/virtual-llm.service.ts index 7a6a8b2..594435f 100644 --- a/src/mcpd/src/services/virtual-llm.service.ts +++ b/src/mcpd/src/services/virtual-llm.service.ts @@ -58,6 +58,12 @@ export interface RegisterProviderInput { * shares the `poolName` with siblings. */ poolName?: string; + /** + * v7: per-user RBAC scoping. When omitted, virtuals default to + * 'private' (visible only to the publishing user). Set explicitly + * to 'public' for org-wide sharing. + */ + visibility?: 'public' | 'private'; } export interface RegisterResult { @@ -134,7 +140,7 @@ export interface EnqueueInferOptions { } export interface IVirtualLlmService { - register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise; + register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise; heartbeat(providerSessionId: string): Promise; bindSession(providerSessionId: string, handle: VirtualSessionHandle): void; unbindSession(providerSessionId: string): Promise; @@ -193,7 +199,7 @@ export class VirtualLlmService implements IVirtualLlmService { private readonly resolveOwner: () => string = () => 'system', ) {} - async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[] }): Promise { + async register(input: { providerSessionId?: string | null; providers: RegisterProviderInput[]; ownerId?: string }): Promise { const sessionId = input.providerSessionId ?? randomUUID(); const now = new Date(); const llms: Llm[] = []; @@ -210,6 +216,12 @@ export class VirtualLlmService implements IVirtualLlmService { description: p.description ?? '', ...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}), ...(p.poolName !== undefined ? { poolName: p.poolName } : {}), + // v7: virtuals default to private ownership. The publisher + // (passed through from the route's authenticated userId) + // owns the row; mcplocal's defaulting + the publisher's + // explicit override land here. + ownerId: input.ownerId ?? null, + visibility: p.visibility ?? 'private', kind: 'virtual', providerSessionId: sessionId, status: initialStatus, @@ -244,6 +256,11 @@ export class VirtualLlmService implements IVirtualLlmService { ...(p.description !== undefined ? { description: p.description } : {}), ...(p.extraConfig !== undefined ? { extraConfig: p.extraConfig } : {}), ...(p.poolName !== undefined ? { poolName: p.poolName } : {}), + // v7: only update visibility on sticky reconnect when the + // publisher explicitly sent it — operators may have flipped a + // virtual to public manually via `mcpctl edit llm`, and we + // don't want a routine reconnect to clobber that. + ...(p.visibility !== undefined ? { visibility: p.visibility } : {}), kind: 'virtual', providerSessionId: sessionId, status: initialStatus, diff --git a/src/mcpd/tests/virtual-llm-routes.test.ts b/src/mcpd/tests/virtual-llm-routes.test.ts index a4477bc..1e4c47f 100644 --- a/src/mcpd/tests/virtual-llm-routes.test.ts +++ b/src/mcpd/tests/virtual-llm-routes.test.ts @@ -75,6 +75,7 @@ describe('POST /api/v1/llms/_provider-register', () => { expect(register).toHaveBeenCalledWith({ providerSessionId: 'sess-xyz', providers: [{ name: 'vllm-local', type: 'openai', model: 'm', tier: 'fast', extraConfig: { gpu: 1 } }], + ownerId: 'system', }); expect(res.json()).toMatchObject({ providerSessionId: 'sess-xyz' }); }); diff --git a/src/mcplocal/src/http/config.ts b/src/mcplocal/src/http/config.ts index b99ebb9..3290280 100644 --- a/src/mcplocal/src/http/config.ts +++ b/src/mcplocal/src/http/config.ts @@ -105,6 +105,14 @@ export interface LlmProviderFileEntry { * logical pool that auto-grows as more workers come online. */ poolName?: string; + /** + * v7: per-user RBAC scoping. mcplocal-published virtuals default to + * 'private' on register — the publishing user owns the row and other + * users don't see it without an explicit `view:llms:` grant. + * Set to 'public' here to opt into org-wide sharing for this + * provider. + */ + visibility?: 'public' | 'private'; } export type WakeRecipe = @@ -136,6 +144,8 @@ export interface AgentFileEntry { project?: string; defaultParams?: Record; extras?: Record; + /** v7: see LlmProviderFileEntry.visibility — same default ('private'). */ + visibility?: 'public' | 'private'; } /** diff --git a/src/mcplocal/src/main.ts b/src/mcplocal/src/main.ts index 3dc842a..892b95f 100644 --- a/src/mcplocal/src/main.ts +++ b/src/mcplocal/src/main.ts @@ -233,6 +233,10 @@ async function maybeStartVirtualLlmRegistrar( if (entry.wake !== undefined) item.wake = entry.wake; if (entry.poolName !== undefined) item.poolName = entry.poolName; if (wireName !== provider.name) item.publishName = wireName; + // v7: pass visibility through; registrar already defaults to + // 'private' when omitted, and the per-provider override flows + // straight through to the register payload. + if (entry.visibility !== undefined) item.visibility = entry.visibility; published.push(item); } // v3: forward locally-declared agents alongside the providers. We @@ -255,6 +259,7 @@ async function maybeStartVirtualLlmRegistrar( if (a.project !== undefined) item.project = a.project; if (a.defaultParams !== undefined) item.defaultParams = a.defaultParams; if (a.extras !== undefined) item.extras = a.extras; + if (a.visibility !== undefined) item.visibility = a.visibility; publishedAgents.push(item); } diff --git a/src/mcplocal/src/providers/registrar.ts b/src/mcplocal/src/providers/registrar.ts index 5e2b17e..4069040 100644 --- a/src/mcplocal/src/providers/registrar.ts +++ b/src/mcplocal/src/providers/registrar.ts @@ -72,6 +72,14 @@ export interface RegistrarPublishedProvider { * `publishName ?? provider.name` everywhere. */ publishName?: string; + /** + * v7: per-user RBAC scoping. mcplocal-published virtuals default to + * 'private' (visible only to the publishing user) — workstations + * shouldn't broadcast their models org-wide unless explicitly + * shared. The publisher can override per provider with + * `"visibility": "public"` in their mcplocal config. + */ + visibility?: 'public' | 'private'; } /** @@ -88,6 +96,8 @@ export interface RegistrarPublishedAgent { project?: string; defaultParams?: Record; extras?: Record; + /** v7: per-user RBAC scoping, defaults to 'private' on register. */ + visibility?: 'public' | 'private'; } export interface RegistrarOptions { @@ -207,6 +217,10 @@ export class VirtualLlmRegistrar { ...(p.tier !== undefined ? { tier: p.tier } : {}), ...(p.description !== undefined ? { description: p.description } : {}), ...(p.poolName !== undefined ? { poolName: p.poolName } : {}), + // v7: virtuals default to private. Operators who want their + // workstation model org-visible set "visibility": "public" per + // provider in mcplocal config. + visibility: p.visibility ?? 'private', initialStatus, }; })); @@ -224,6 +238,9 @@ export class VirtualLlmRegistrar { ...(a.project !== undefined ? { project: a.project } : {}), ...(a.defaultParams !== undefined ? { defaultParams: a.defaultParams } : {}), ...(a.extras !== undefined ? { extras: a.extras } : {}), + // v7: forward visibility to mcpd. Defaults to 'private' for + // virtual agents on the server side when omitted. + visibility: a.visibility ?? 'private', })); } -- 2.49.1 From 2b2444a2c59868024a93b58454af60446b8dfe50 Mon Sep 17 00:00:00 2001 From: Michal Date: Wed, 29 Apr 2026 01:08:03 +0100 Subject: [PATCH 03/18] docs+smoke(v7): visibility section in virtual-llms.md + register/list smoke Wraps up v7 Stage 3: - docs/virtual-llms.md gains a "Visibility scope (v7)" section that explains public-vs-private semantics, who skips the filter (owner + `*` admin), how to grant single-row exceptions via name-scoped RBAC, per-row override syntax in mcplocal config, the `--visibility` flag on `mcpctl create llm`/`create agent`, and YAML round-trip behavior. - New smoke (virtual-llm-visibility.smoke.test.ts) publishes one public + one private virtual Llm via the registrar against the live mcpd and asserts the GET /llms response carries visibility + a non-empty ownerId for both, and that GET /llms/ returns the private row to its owner without 404. Cross-user filtering is covered by mcpd's visibility-filter unit tests; smoke proves the fields make the round-trip end-to-end. Will pass once mcpd is rebuilt + deployed via fulldeploy.sh on this branch (current main is v6, doesn't yet serialize visibility). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/virtual-llms.md | 94 +++++++- .../virtual-llm-visibility.smoke.test.ts | 208 ++++++++++++++++++ 2 files changed, 300 insertions(+), 2 deletions(-) create mode 100644 src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts diff --git a/docs/virtual-llms.md b/docs/virtual-llms.md index d8fd354..6f5b458 100644 --- a/docs/virtual-llms.md +++ b/docs/virtual-llms.md @@ -431,10 +431,100 @@ mid-task reverts the row to pending instead of failing the caller. See [inference-tasks.md](./inference-tasks.md) for the full data model, async API, lifecycle, RBAC, and CLI surface. +## Visibility scope (v7) + +Virtual Llms and Agents now carry an explicit **visibility** field that +decides who can see the row in listings. + +| Visibility | Meaning | +|-------------|----------------------------------------------------------------------------------| +| `public` | Visible to anyone with `view:llms` / `view:agents`. Default for hand-created Llms. | +| `private` | Only the **owner** plus principals with a name-scoped grant can see it. Default for virtual Llms and Agents on first publish. | + +The owner is whichever user authenticated the publishing +`POST /api/v1/llms/_provider-register` (or `mcpctl create llm`). For +mcplocal that's whichever `~/.mcpctl/credentials` token is on disk. +Legacy rows from before v7 default to `visibility=public, ownerId=NULL`, +so the upgrade is a no-op for everything that already exists. + +### Who skips the filter? + +Two principals see every row regardless of visibility: + +1. The **row owner** (`ownerId === request.userId`). +2. Anyone with a **cross-resource admin** grant — RBAC binding + `{ resource: '*' }`. Operationally this is the SRE / cluster admin. + +A plain `view:llms` resource grant is *not* the same as admin: it's a +RBAC wildcard for name-scoping (you can name any Llm), but the +visibility filter still applies on top. This is the v7 split that +prevents a user with `view:llms` from enumerating every developer's +private virtual Llm. + +### Granting a single-row exception + +When alice wants bob to see her private virtual Llm `alice-vllm-local` +without making it public, she binds: + +```sh +mcpctl create rbac bob view:llms --name alice-vllm-local +``` + +Same shape as any other name-scoped binding. Removing the binding +flips bob back to "row not found". + +### Publishing as private from mcplocal + +mcplocal defaults to `private` for every published provider and agent. +Override per-row in `~/.mcpctl/config.json`: + +```jsonc +{ + "llm": { + "providers": [ + { "name": "vllm-local", "type": "vllm", "model": "...", "publish": true, + "visibility": "private" }, // default; explicit for clarity + { "name": "shared-qwen", "type": "vllm", "model": "...", "publish": true, + "visibility": "public" } // every team member can chat with it + ] + }, + "agents": [ + { "name": "local-coder", "llm": "vllm-local", + "visibility": "private" } // private agents pinned to private Llms + ] +} +``` + +On a sticky reconnect (`providerSessionId` matches an existing row) +the visibility is **only** updated when the publisher explicitly sends +it — leaving the field off keeps whatever the row already has, +including any field admin set out-of-band. + +### Hand-created Llms + +`mcpctl create llm` defaults to `public` (matches pre-v7 behavior). +Pass `--visibility private` to opt in: + +```sh +mcpctl create llm my-key --type openai --model gpt-4o \ + --api-key-ref my-secret/key --visibility private +``` + +The same `--visibility` flag is on `mcpctl create agent`. + +### CLI surface + +`mcpctl get llm` and `mcpctl get agent` show a `VISIBILITY` column. +YAML round-trips cleanly: `mcpctl get llm X -o yaml | mcpctl apply -f -` +preserves visibility, and `ownerId` is stripped from the apply doc +because it's server-side state (the apply re-stamps the ownerId of the +authenticated caller, not the original creator). + ## Roadmap (later stages) -(LB pool by name landed in v4; durable task queue landed in v5.) -- **v6** — multi-instance mcpd via pg `LISTEN/NOTIFY` (replaces the +(LB pool by name landed in v4; durable task queue landed in v5; +visibility scope landed in v7.) +- **v8** — multi-instance mcpd via pg `LISTEN/NOTIFY` (replaces the per-instance EventEmitter wakeup), per-session worker capacity, remote cancel protocol over the SSE channel. diff --git a/src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts b/src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts new file mode 100644 index 0000000..a41c0e4 --- /dev/null +++ b/src/mcplocal/tests/smoke/virtual-llm-visibility.smoke.test.ts @@ -0,0 +1,208 @@ +/** + * Smoke: v7 visibility round-trip. + * + * Publishes two virtual Llms via the registrar — one explicitly public, + * one explicitly private — and verifies the GET /api/v1/llms response + * carries the visibility + ownerId fields end-to-end. The cross-user + * filter (private rows hidden from non-owner non-admin) is fully + * covered by mcpd's visibility-filter unit tests; smoke only proves + * the new fields make the round-trip from registrar → mcpd → list + * payload without dropping or being defaulted away. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import http from 'node:http'; +import https from 'node:https'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + VirtualLlmRegistrar, + type RegistrarPublishedProvider, +} from '../../src/providers/registrar.js'; +import type { LlmProvider, CompletionResult } from '../../src/providers/types.js'; + +const MCPD_URL = process.env.MCPD_URL ?? 'https://mcpctl.ad.itaz.eu'; +const SUFFIX = Date.now().toString(36); +const PUBLIC_NAME = `smoke-vis-public-${SUFFIX}`; +const PRIVATE_NAME = `smoke-vis-private-${SUFFIX}`; + +function makeFakeProvider(name: string): LlmProvider { + return { + name, + async complete(): Promise { + return { + content: 'ok', + toolCalls: [], + usage: { promptTokens: 1, completionTokens: 1, totalTokens: 2 }, + finishReason: 'stop', + }; + }, + async listModels() { return []; }, + async isAvailable() { return true; }, + }; +} + +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); }); + }); +} + +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; + } +} + +interface HttpResponse { status: number; body: string } + +function httpRequest(method: string, urlStr: string): 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', + ...(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: 30_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}`)); }); + req.end(); + }); +} + +let mcpdUp = false; +let registrar: VirtualLlmRegistrar | null = null; +let tempDir: string; + +interface LlmListRow { + id: string; + name: string; + visibility?: 'public' | 'private'; + ownerId?: string | null; +} + +describe('virtual-LLM smoke — visibility (v7)', () => { + beforeAll(async () => { + mcpdUp = await healthz(MCPD_URL); + if (!mcpdUp) { + // eslint-disable-next-line no-console + console.warn(`\n ○ visibility smoke: skipped — ${MCPD_URL}/healthz unreachable.\n`); + return; + } + if (readToken() === null) { + mcpdUp = false; + // eslint-disable-next-line no-console + console.warn('\n ○ visibility smoke: skipped — no ~/.mcpctl/credentials.\n'); + return; + } + tempDir = mkdtempSync(join(tmpdir(), 'mcpctl-vis-smoke-')); + }, 20_000); + + afterAll(async () => { + if (registrar !== null) registrar.stop(); + if (tempDir !== undefined) rmSync(tempDir, { recursive: true, force: true }); + if (mcpdUp) { + const list = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`); + if (list.status === 200) { + const rows = JSON.parse(list.body) as LlmListRow[]; + for (const target of [PUBLIC_NAME, PRIVATE_NAME]) { + const row = rows.find((r) => r.name === target); + if (row !== undefined) { + await httpRequest('DELETE', `${MCPD_URL}/api/v1/llms/${row.id}`); + } + } + } + } + }); + + it('publishes one public + one private virtual Llm and the list payload reflects both', async () => { + if (!mcpdUp) return; + const token = readToken(); + if (token === null) return; + const published: RegistrarPublishedProvider[] = [ + { provider: makeFakeProvider(PUBLIC_NAME), type: 'openai', model: 'fake-vis', tier: 'fast', visibility: 'public' }, + { provider: makeFakeProvider(PRIVATE_NAME), type: 'openai', model: 'fake-vis', tier: 'fast', visibility: 'private' }, + ]; + registrar = new VirtualLlmRegistrar({ + mcpdUrl: MCPD_URL, + token, + publishedProviders: published, + sessionFilePath: join(tempDir, 'session'), + log: { info: () => {}, warn: () => {}, error: () => {} }, + heartbeatIntervalMs: 60_000, + }); + await registrar.start(); + expect(registrar.getSessionId()).not.toBeNull(); + await new Promise((r) => setTimeout(r, 400)); + + const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms`); + expect(res.status).toBe(200); + const rows = JSON.parse(res.body) as LlmListRow[]; + + const pub = rows.find((r) => r.name === PUBLIC_NAME); + expect(pub, `${PUBLIC_NAME} must be visible to its owner`).toBeDefined(); + expect(pub!.visibility).toBe('public'); + // ownerId is the auth principal that ran register; non-empty proves + // mcpd actually stamped it on the row (otherwise the v7 register + // path would have left it NULL = legacy public). + expect(typeof pub!.ownerId).toBe('string'); + expect((pub!.ownerId ?? '').length).toBeGreaterThan(0); + + const priv = rows.find((r) => r.name === PRIVATE_NAME); + expect(priv, `${PRIVATE_NAME} must be visible to its owner (visibility filter is owner-bypass)`).toBeDefined(); + expect(priv!.visibility).toBe('private'); + expect(typeof priv!.ownerId).toBe('string'); + expect((priv!.ownerId ?? '').length).toBeGreaterThan(0); + + // Same publisher, same session — both rows must share the same owner. + expect(priv!.ownerId).toBe(pub!.ownerId); + }, 30_000); + + it('GET /api/v1/llms/ returns the row to its owner without 404', async () => { + if (!mcpdUp) return; + // Owner is calling — visibility filter must let the row through. A + // 404 here would indicate the service-layer filter is wrongly hiding + // it from the very user who created it. + const res = await httpRequest('GET', `${MCPD_URL}/api/v1/llms/${PRIVATE_NAME}`); + expect(res.status).toBe(200); + const row = JSON.parse(res.body) as LlmListRow; + expect(row.name).toBe(PRIVATE_NAME); + expect(row.visibility).toBe('private'); + }, 30_000); +}); -- 2.49.1 From fbe68fa6935520c02f35c06fea5f2286e4840bd9 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 00:18:21 +0100 Subject: [PATCH 04/18] feat(db): schema for ResourceRevision, ResourceProposal, Skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 of the Skills + Revisions + Proposals work. Purely additive — no existing rows are touched, no tables renamed, no columns dropped. New tables: - ResourceRevision — append-only audit + diff log keyed by (resourceType, resourceId). Both Prompt and Skill produce revisions on every change. Soft FK so revisions outlive the resources they describe. Indexed for history viewer (latest-first), semver lookup, and cross-resource sync diff via contentHash. - ResourceProposal — generic propose/approve/reject queue. Drop-in replacement for the prompt-only PromptRequest. Created empty here; PR-2 will rename PromptRequest → _PromptRequest_legacy and backfill. - Skill — new resource type that mirrors Prompt for everything CRUD- shaped. Adds `files` Json (multi-file bundles, materialised onto disk by `mcpctl skills sync` in PR-5) and `metadata` Json (typed app-layer in PR-3: hooks, mcpServers, postInstall, …). New columns on Prompt: - semver (semver string, default '0.1.0') — auto-bumped patch on save by PromptService.update once PR-2 wires it. Distinct from `version`, which stays as the optimistic-concurrency counter. - currentRevisionId — soft pointer to the latest ResourceRevision row. DB tests cover scope rules (project XOR agent XOR neither), name uniqueness across both compound keys, cascade-on-delete, soft-FK survival of deletion, and JSON column persistence. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../migration.sql | 157 ++++++++++++ src/db/prisma/schema.prisma | 230 ++++++++++++++---- src/db/tests/helpers.ts | 6 + src/db/tests/resource-proposal-schema.test.ts | 147 +++++++++++ src/db/tests/resource-revision-schema.test.ts | 119 +++++++++ src/db/tests/skill-schema.test.ts | 192 +++++++++++++++ 6 files changed, 810 insertions(+), 41 deletions(-) create mode 100644 src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql create mode 100644 src/db/tests/resource-proposal-schema.test.ts create mode 100644 src/db/tests/resource-revision-schema.test.ts create mode 100644 src/db/tests/skill-schema.test.ts diff --git a/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql b/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql new file mode 100644 index 0000000..93b2c05 --- /dev/null +++ b/src/db/prisma/migrations/20260507000001_add_resource_revisions_proposals_skills/migration.sql @@ -0,0 +1,157 @@ +-- Phase 1 of the Skills+Revisions+Proposals work. Purely additive — no +-- existing rows are touched, no tables renamed, no columns dropped. PR-2 +-- will follow up with the PromptRequest → ResourceProposal cutover (rename +-- + backfill + service rewire) once the new tables have settled. +-- +-- New objects: +-- 1. ResourceRevision — append-only audit + diff log keyed by +-- (resourceType, resourceId). Both Prompt and Skill produce revisions. +-- Hot reads stay on the resource row's inline content; revisions are +-- only consulted by history/diff/restore endpoints. +-- 2. ResourceProposal — generic propose/approve/reject queue, drop-in +-- replacement for the prompt-only PromptRequest. Created empty here; +-- backfill from PromptRequest happens in PR-2. +-- 3. Skill — new resource type that mirrors Prompt for everything CRUD- +-- shaped. Adds `files` Json (multi-file bundles, materialised onto +-- disk by `mcpctl skills sync`) and `metadata` Json (typed at the +-- app layer in PR-3: hooks, mcpServers, postInstall, …). +-- 4. semver + currentRevisionId columns on Prompt. + +-- ── 1. ResourceRevision ── +CREATE TABLE "ResourceRevision" ( + "id" TEXT NOT NULL, + -- Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a + -- third resource type later a non-migration change. + "resourceType" TEXT NOT NULL, + -- Soft FK — no relation declared. Survives resource deletion so the + -- audit trail isn't lost when a prompt or skill is removed. + "resourceId" TEXT NOT NULL, + "semver" TEXT NOT NULL DEFAULT '0.1.0', + -- sha256 of the canonicalised body — stable diff key. Two revisions + -- with the same hash are byte-identical (skills sync uses this to + -- skip work even when semver hasn't bumped). + "contentHash" TEXT NOT NULL, + -- Snapshot of the resource at this revision: { content, metadata?, ... } + "body" JSONB NOT NULL, + "authorUserId" TEXT, + "authorSessionId" TEXT, + "note" TEXT NOT NULL DEFAULT '', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ResourceRevision_pkey" PRIMARY KEY ("id") +); + +-- History viewer: latest-first within a resource. +CREATE INDEX "ResourceRevision_resourceType_resourceId_createdAt_idx" + ON "ResourceRevision"("resourceType", "resourceId", "createdAt" DESC); +-- Direct lookup by semver: GET /api/v1/revisions?resourceType=&resourceId=&semver= +CREATE INDEX "ResourceRevision_resourceType_resourceId_semver_idx" + ON "ResourceRevision"("resourceType", "resourceId", "semver"); +-- Cross-resource sync diff: "do I already have a revision with this hash?" +CREATE INDEX "ResourceRevision_contentHash_idx" + ON "ResourceRevision"("contentHash"); +-- Author drilldown for audit views. +CREATE INDEX "ResourceRevision_authorUserId_idx" + ON "ResourceRevision"("authorUserId"); + +-- ── 2. ResourceProposal ── +CREATE TABLE "ResourceProposal" ( + "id" TEXT NOT NULL, + "resourceType" TEXT NOT NULL, + "name" TEXT NOT NULL, + -- Proposed body — { content, metadata? } shaped per resourceType. + "body" JSONB NOT NULL, + "projectId" TEXT, + "agentId" TEXT, + "createdBySession" TEXT, + "createdByUserId" TEXT, + -- Status lifecycle: pending → (approved|rejected). Reviewer note set + -- on either terminal state. + "status" TEXT NOT NULL DEFAULT 'pending', + "reviewerNote" TEXT NOT NULL DEFAULT '', + -- Set when status='approved': the ResourceRevision the approval created. + "approvedRevisionId" TEXT, + -- Optimistic-concurrency counter for concurrent reviewer actions. + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ResourceProposal_pkey" PRIMARY KEY ("id") +); + +-- Cascade matches Prompt's behaviour: deleting a project drops its proposals. +ALTER TABLE "ResourceProposal" + ADD CONSTRAINT "ResourceProposal_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "ResourceProposal" + ADD CONSTRAINT "ResourceProposal_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +-- Mirrors Prompt's two-unique pattern: a (type, name) pair can have one +-- proposal per project XOR one per agent. NULL semantics inherit Postgres +-- defaults; the app layer reuses the `?? ''` workaround for direct +-- compound-key lookups, same as Prompt. +CREATE UNIQUE INDEX "ResourceProposal_resourceType_name_projectId_key" + ON "ResourceProposal"("resourceType", "name", "projectId"); +CREATE UNIQUE INDEX "ResourceProposal_resourceType_name_agentId_key" + ON "ResourceProposal"("resourceType", "name", "agentId"); +-- Reviewer queue: SELECT … WHERE resourceType=? AND status='pending'. +CREATE INDEX "ResourceProposal_resourceType_status_idx" + ON "ResourceProposal"("resourceType", "status"); +CREATE INDEX "ResourceProposal_projectId_idx" + ON "ResourceProposal"("projectId"); +CREATE INDEX "ResourceProposal_createdBySession_idx" + ON "ResourceProposal"("createdBySession"); + +-- ── 3. Skill ── +-- Mirrors Prompt for scoping (project XOR agent XOR neither = global) and +-- carries the inline SKILL.md body in `content`. Multi-file bundles live +-- in `files` (path → content map). Typed skill metadata (hooks, +-- mcpServers, postInstall, …) lives opaquely in `metadata` here and is +-- validated app-layer (PR-3). +CREATE TABLE "Skill" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "content" TEXT NOT NULL, + "files" JSONB NOT NULL DEFAULT '{}', + "metadata" JSONB NOT NULL DEFAULT '{}', + "projectId" TEXT, + "agentId" TEXT, + "priority" INTEGER NOT NULL DEFAULT 5, + "summary" TEXT, + "chapters" JSONB, + "semver" TEXT NOT NULL DEFAULT '0.1.0', + -- Soft pointer to the latest ResourceRevision row for this skill. + -- NULL before the first revision is recorded. + "currentRevisionId" TEXT, + -- Optimistic-concurrency counter. NOT semver — that's `semver` above. + "version" INTEGER NOT NULL DEFAULT 1, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Skill_pkey" PRIMARY KEY ("id") +); + +ALTER TABLE "Skill" + ADD CONSTRAINT "Skill_projectId_fkey" + FOREIGN KEY ("projectId") REFERENCES "Project"("id") + ON DELETE CASCADE ON UPDATE CASCADE; +ALTER TABLE "Skill" + ADD CONSTRAINT "Skill_agentId_fkey" + FOREIGN KEY ("agentId") REFERENCES "Agent"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + +CREATE UNIQUE INDEX "Skill_name_projectId_key" ON "Skill"("name", "projectId"); +CREATE UNIQUE INDEX "Skill_name_agentId_key" ON "Skill"("name", "agentId"); +CREATE INDEX "Skill_projectId_idx" ON "Skill"("projectId"); +CREATE INDEX "Skill_agentId_idx" ON "Skill"("agentId"); +CREATE INDEX "Skill_name_idx" ON "Skill"("name"); + +-- ── 4. semver + currentRevisionId on Prompt ── +-- ADD COLUMN with a default is instant on Postgres ≥11 (no table rewrite). +ALTER TABLE "Prompt" + ADD COLUMN "semver" TEXT NOT NULL DEFAULT '0.1.0', + ADD COLUMN "currentRevisionId" TEXT; diff --git a/src/db/prisma/schema.prisma b/src/db/prisma/schema.prisma index 4289ae0..3e19b63 100644 --- a/src/db/prisma/schema.prisma +++ b/src/db/prisma/schema.prisma @@ -300,10 +300,12 @@ model Project { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) + owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade) servers ProjectServer[] prompts Prompt[] promptRequests PromptRequest[] + proposals ResourceProposal[] + skills Skill[] mcpTokens McpToken[] agents Agent[] @@ -386,18 +388,27 @@ enum InstanceStatus { // ── Prompts (approved content resources) ── model Prompt { - id String @id @default(cuid()) - name String - content String @db.Text - projectId String? - agentId String? - priority Int @default(5) - summary String? @db.Text - chapters Json? - linkTarget String? - version Int @default(1) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + name String + content String @db.Text + projectId String? + agentId String? + priority Int @default(5) + summary String? @db.Text + chapters Json? + linkTarget String? + // Semantic version of the current content. Auto-bumped patch on every save + // by PromptService.update; author can pass --bump major|minor|patch or + // --semver X.Y.Z to override. NOT the same as `version` below — that one + // is the optimistic-concurrency counter and stays Int. + semver String @default("0.1.0") + // Soft pointer to the latest ResourceRevision row for this prompt. NULL + // before the first revision is recorded. Set in the same transaction as + // create/update by PromptService. + currentRevisionId String? + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) @@ -409,6 +420,56 @@ model Prompt { @@index([agentId]) } +// ── Skills (Claude Code skill bundles, synced to ~/.claude/skills//) ── +// +// Skills are the on-disk counterpart to Prompts. mcpd is the source of truth; +// mcpctl skills sync materialises them onto disk under +// ~/.claude/skills//SKILL.md (+ optional aux files) where Claude Code +// reads them natively. Same scoping rules as Prompt (project XOR agent XOR +// neither = global). Multi-file bundles live in `files` (path → content). +// Typed skill metadata (hooks, mcpServers, postInstall, …) is validated in +// the app layer and stored opaquely here in `metadata`. + +model Skill { + id String @id @default(cuid()) + name String + description String @default("") + // Body of the SKILL.md file delivered to Claude Code. + content String @db.Text + // Auxiliary files in the skill bundle. Map of relative path → file content + // (UTF-8 text only in v1; binaries deferred). Materialised onto disk at sync + // time alongside SKILL.md. + files Json @default("{}") + // Typed-but-stored-as-Json: { hooks?, mcpServers?, postInstall?, + // preUninstall?, postInstallTimeoutSec? }. Validated at the route layer + // (see src/mcpd/src/validation/skill.schema.ts in PR-3). + metadata Json @default("{}") + projectId String? + agentId String? + priority Int @default(5) + summary String? @db.Text + chapters Json? + // Semantic version of the current content. Auto-bumped patch on every save + // by SkillService.update; author can override via --bump or --semver. + semver String @default("0.1.0") + // Soft pointer to the latest ResourceRevision row for this skill. NULL + // before the first revision is recorded. + currentRevisionId String? + // Optimistic-concurrency counter. NOT semver — that's `semver` above. + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) + + @@unique([name, projectId]) + @@unique([name, agentId]) + @@index([projectId]) + @@index([agentId]) + @@index([name]) +} + // ── Prompt Requests (pending proposals from LLM sessions) ── model PromptRequest { @@ -428,6 +489,91 @@ model PromptRequest { @@index([createdBySession]) } +// ── ResourceRevision (append-only audit + diff log) ── +// +// Both Prompt and Skill rows produce revisions on every change. Hot reads +// (gate plugin, mcpctl skills sync, prompt index for LLM selection) stay on +// the resource row's inline content; revisions are only consulted by +// history/diff/restore endpoints. Approving a ResourceProposal atomically +// inserts the resource + a revision in the same transaction. +// +// `resourceId` is a soft FK with NO referential constraint — revisions +// outlive the resources they describe so audit history isn't lost when +// a resource is deleted. Validated app-layer. + +model ResourceRevision { + id String @id @default(cuid()) + // Discriminator: 'prompt' | 'skill'. TEXT, not enum, to make adding a + // third resource type later a non-migration change. + resourceType String + resourceId String + semver String @default("0.1.0") + // sha256 of the canonicalised body — stable diff key. Two revisions with + // the same hash are byte-identical (skills sync uses this to skip work + // even when semver hasn't bumped). + contentHash String + // Snapshot of the resource at this revision: { content, metadata?, ... } + body Json + authorUserId String? + authorSessionId String? + note String @default("") + createdAt DateTime @default(now()) + + // History viewer: latest-first within a resource. + @@index([resourceType, resourceId, createdAt(sort: Desc)]) + // Direct lookup by semver. + @@index([resourceType, resourceId, semver]) + // Sync diff cross-resource lookup ("do I already have a revision with + // this contentHash anywhere?") — useful for detecting renames + dedup. + @@index([contentHash]) + @@index([authorUserId]) +} + +// ── ResourceProposal (generic propose/approve/reject queue) ── +// +// A pending change to a Prompt or Skill, submitted by an LLM session via +// the propose_prompt / propose_skill MCP tools (see mcplocal gate plugin) +// or by a human via the web UI / CLI. Reviewers drain the queue via +// `mcpctl review next`. Approving creates the underlying resource (if new) +// and writes a ResourceRevision; the proposal status flips to 'approved' +// and `approvedRevisionId` points at the resulting revision row. +// +// Replaces the prompt-only PromptRequest table; the cutover happens in PR-2 +// (rename + backfill + service rewire). + +model ResourceProposal { + id String @id @default(cuid()) + resourceType String // 'prompt' | 'skill' + name String + // Proposed body — { content, metadata? } shaped per resourceType. + body Json + projectId String? + agentId String? + createdBySession String? + createdByUserId String? + status String @default("pending") // pending | approved | rejected + reviewerNote String @default("") + // Set when status='approved': the ResourceRevision the approval created. + approvedRevisionId String? + // Optimistic-concurrency counter for concurrent reviewer actions. + version Int @default(1) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + project Project? @relation(fields: [projectId], references: [id], onDelete: Cascade) + agent Agent? @relation(fields: [agentId], references: [id], onDelete: Cascade) + + // Mirrors Prompt's two-unique pattern: a (type, name) pair can have one + // proposal per project XOR one per agent. NULL semantics inherit Postgres + // defaults (NULL distinct from NULL); the app layer reuses the `?? ''` + // workaround for direct compound-key lookups, same as Prompt. + @@unique([resourceType, name, projectId]) + @@unique([resourceType, name, agentId]) + @@index([resourceType, status]) + @@index([projectId]) + @@index([createdBySession]) +} + // ── Audit Events (pipeline/gate/tool trace from mcplocal) ── model AuditEvent { @@ -499,13 +645,15 @@ model Agent { 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) + 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) + proposals ResourceProposal[] + skills Skill[] + personalities Personality[] @relation("AgentPersonalities") + defaultPersonality Personality? @relation("AgentDefaultPersonality", fields: [defaultPersonalityId], references: [id], onDelete: SetNull) @@index([name]) @@index([llmId]) @@ -619,54 +767,54 @@ model ChatMessage { // SSE channel — that's how queued tasks survive worker offline windows. enum InferenceTaskStatus { - pending // in queue, no worker has it yet (or claim was reverted) - claimed // a worker has it (SSE frame sent), no chunks back yet - running // worker started streaming chunks back (streaming tasks only) - completed // worker POSTed the final result - error // permanent failure (auth, bad request, queue timeout) - cancelled // caller said never mind via DELETE + pending // in queue, no worker has it yet (or claim was reverted) + claimed // a worker has it (SSE frame sent), no chunks back yet + running // worker started streaming chunks back (streaming tasks only) + completed // worker POSTed the final result + error // permanent failure (auth, bad request, queue timeout) + cancelled // caller said never mind via DELETE } model InferenceTask { - id String @id @default(cuid()) - status InferenceTaskStatus @default(pending) + id String @id @default(cuid()) + status InferenceTaskStatus @default(pending) // Routing — pool key drives worker matching at claim time. Stored at // enqueue time so a later rename of Llm.poolName doesn't reroute // already-queued work. - poolName String - llmName String // pinned target Llm name (for audit + agent backref) - model String - tier String? + poolName String + llmName String // pinned target Llm name (for audit + agent backref) + model String + tier String? // Worker tracking. NULL while pending; set on claim; cleared on // unbindSession-driven revert (worker disconnect mid-task). - claimedBy String? + claimedBy String? // Body + result. Both are Json so streaming chunks can be reconstructed // (see TaskService.complete) and async pollers get a structured payload. // requestBody is required (the OpenAI chat-completion request body the // worker should run); responseBody is null until status=completed. - requestBody Json - responseBody Json? - errorMessage String? + requestBody Json + responseBody Json? + errorMessage String? /** * Whether the original request asked for streaming. Drives the chunk-vs- * final-body protocol on the result POST and tells async API callers * whether `/stream` will yield chunks or just a single completion event. */ - streaming Boolean @default(false) + streaming Boolean @default(false) // Timestamps for observability + GC: // pending → claimed: claimedAt set // claimed → running: streamStartedAt set (first chunk received) // running/claimed → completed/error/cancelled: completedAt set - createdAt DateTime @default(now()) - claimedAt DateTime? - streamStartedAt DateTime? - completedAt DateTime? + createdAt DateTime @default(now()) + claimedAt DateTime? + streamStartedAt DateTime? + completedAt DateTime? // Caller tracking — RBAC + observability. ownerId references User.id; // agentId is set when the task came in via /agents//chat (null // for direct /llms//infer or async POST /inference-tasks calls // that don't pin an agent). - ownerId String - agentId String? + ownerId String + agentId String? @@index([status, poolName]) @@index([claimedBy]) diff --git a/src/db/tests/helpers.ts b/src/db/tests/helpers.ts index 7083b75..8926ba1 100644 --- a/src/db/tests/helpers.ts +++ b/src/db/tests/helpers.ts @@ -36,6 +36,12 @@ export async function clearAllTables(client: PrismaClient): Promise { // Break Agent.defaultPersonalityId before personalities can be removed. await client.agent.updateMany({ data: { defaultPersonalityId: null } }); await client.personality.deleteMany(); + // Skills + Proposals cascade from Project/Agent, but globals (NULL FK) + // need explicit cleanup so they don't leak between tests. + await client.skill.deleteMany(); + await client.resourceProposal.deleteMany(); + // Revisions have no FK (soft FK); always orphans without explicit clear. + await client.resourceRevision.deleteMany(); await client.agent.deleteMany(); await client.llm.deleteMany(); await client.mcpInstance.deleteMany(); diff --git a/src/db/tests/resource-proposal-schema.test.ts b/src/db/tests/resource-proposal-schema.test.ts new file mode 100644 index 0000000..96ad372 --- /dev/null +++ b/src/db/tests/resource-proposal-schema.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('ResourceProposal schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + async function createUser() { + return prisma.user.create({ + data: { + email: `test-${Date.now()}-${Math.random()}@example.com`, + name: 'Test', + passwordHash: '!locked', + role: 'USER', + }, + }); + } + + async function createProject(name = `project-${Date.now()}-${Math.random()}`) { + const user = await createUser(); + return prisma.project.create({ data: { name, ownerId: user.id } }); + } + + it('creates a pending prompt proposal with defaults', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'my-proposal', + body: { content: 'hello', priority: 5 }, + projectId: project.id, + }, + }); + expect(proposal.id).toBeDefined(); + expect(proposal.status).toBe('pending'); + expect(proposal.reviewerNote).toBe(''); + expect(proposal.approvedRevisionId).toBeNull(); + expect(proposal.version).toBe(1); + expect(proposal.body).toEqual({ content: 'hello', priority: 5 }); + }); + + it('creates a pending skill proposal', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'skill', + name: 'my-skill-proposal', + body: { content: 'SKILL.md body', metadata: { postInstall: 'hooks/x.sh' } }, + projectId: project.id, + }, + }); + expect(proposal.resourceType).toBe('skill'); + }); + + it('enforces unique (resourceType, name, projectId)', async () => { + const project = await createProject(); + await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'dup', + body: { content: 'a' }, + projectId: project.id, + }, + }); + await expect( + prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'dup', + body: { content: 'b' }, + projectId: project.id, + }, + }), + ).rejects.toThrow(); + }); + + it('allows same name across different resource types in same project', async () => { + const project = await createProject(); + await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'shared', + body: { content: 'a' }, + projectId: project.id, + }, + }); + const second = await prisma.resourceProposal.create({ + data: { + resourceType: 'skill', + name: 'shared', + body: { content: 'b' }, + projectId: project.id, + }, + }); + expect(second.id).toBeDefined(); + }); + + it('allows status lifecycle: pending → approved', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'flow', + body: { content: 'hi' }, + projectId: project.id, + }, + }); + const approved = await prisma.resourceProposal.update({ + where: { id: proposal.id }, + data: { + status: 'approved', + reviewerNote: 'looks good', + approvedRevisionId: 'rev-fake-123', + }, + }); + expect(approved.status).toBe('approved'); + expect(approved.reviewerNote).toBe('looks good'); + expect(approved.approvedRevisionId).toBe('rev-fake-123'); + }); + + it('cascades on project delete', async () => { + const project = await createProject(); + const proposal = await prisma.resourceProposal.create({ + data: { + resourceType: 'prompt', + name: 'will-cascade', + body: { content: 'hi' }, + projectId: project.id, + }, + }); + await prisma.project.delete({ where: { id: project.id } }); + const found = await prisma.resourceProposal.findUnique({ where: { id: proposal.id } }); + expect(found).toBeNull(); + }); +}); diff --git a/src/db/tests/resource-revision-schema.test.ts b/src/db/tests/resource-revision-schema.test.ts new file mode 100644 index 0000000..98e2e30 --- /dev/null +++ b/src/db/tests/resource-revision-schema.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('ResourceRevision schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + it('creates a revision with required fields and defaults', async () => { + const rev = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', + resourceId: 'fake-prompt-id', + contentHash: 'sha256:abc', + body: { content: 'hello' }, + }, + }); + expect(rev.id).toBeDefined(); + expect(rev.semver).toBe('0.1.0'); + expect(rev.note).toBe(''); + expect(rev.authorUserId).toBeNull(); + expect(rev.authorSessionId).toBeNull(); + expect(rev.body).toEqual({ content: 'hello' }); + expect(rev.createdAt).toBeInstanceOf(Date); + }); + + it('survives resource deletion (soft FK)', async () => { + // No actual prompt exists with this id — the soft FK design lets + // revisions outlive their resources. + const rev = await prisma.resourceRevision.create({ + data: { + resourceType: 'skill', + resourceId: 'never-existed', + contentHash: 'sha256:def', + body: { content: 'ghost' }, + }, + }); + expect(rev.resourceId).toBe('never-existed'); + }); + + it('orders revisions latest-first within a resource', async () => { + const resourceId = 'r1'; + const a = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.1.0', contentHash: 'h1', body: {}, + }, + }); + await new Promise((r) => setTimeout(r, 10)); + const b = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.1.1', contentHash: 'h2', body: {}, + }, + }); + await new Promise((r) => setTimeout(r, 10)); + const c = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.2.0', contentHash: 'h3', body: {}, + }, + }); + + const rows = await prisma.resourceRevision.findMany({ + where: { resourceType: 'prompt', resourceId }, + orderBy: { createdAt: 'desc' }, + }); + expect(rows.map((r) => r.id)).toEqual([c.id, b.id, a.id]); + }); + + it('allows multiple revisions with the same contentHash (rollback)', async () => { + const resourceId = 'r2'; + await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.1.0', contentHash: 'identical', body: {}, + }, + }); + const second = await prisma.resourceRevision.create({ + data: { + resourceType: 'prompt', resourceId, + semver: '0.2.0', contentHash: 'identical', body: {}, + }, + }); + expect(second.id).toBeDefined(); + const rows = await prisma.resourceRevision.findMany({ + where: { contentHash: 'identical' }, + }); + expect(rows.length).toBe(2); + }); + + it('discriminates between prompt and skill revisions', async () => { + await prisma.resourceRevision.create({ + data: { resourceType: 'prompt', resourceId: 'x', contentHash: 'a', body: {} }, + }); + await prisma.resourceRevision.create({ + data: { resourceType: 'skill', resourceId: 'x', contentHash: 'a', body: {} }, + }); + const prompts = await prisma.resourceRevision.findMany({ + where: { resourceType: 'prompt', resourceId: 'x' }, + }); + const skills = await prisma.resourceRevision.findMany({ + where: { resourceType: 'skill', resourceId: 'x' }, + }); + expect(prompts.length).toBe(1); + expect(skills.length).toBe(1); + }); +}); diff --git a/src/db/tests/skill-schema.test.ts b/src/db/tests/skill-schema.test.ts new file mode 100644 index 0000000..86d741e --- /dev/null +++ b/src/db/tests/skill-schema.test.ts @@ -0,0 +1,192 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import type { PrismaClient } from '@prisma/client'; +import { setupTestDb, cleanupTestDb, clearAllTables } from './helpers.js'; + +describe('Skill schema', () => { + let prisma: PrismaClient; + + beforeAll(async () => { + prisma = await setupTestDb(); + }, 30_000); + + afterAll(async () => { + await cleanupTestDb(); + }); + + beforeEach(async () => { + await clearAllTables(prisma); + }); + + async function createUser() { + return prisma.user.create({ + data: { + email: `test-${Date.now()}-${Math.random()}@example.com`, + name: 'Test', + passwordHash: '!locked', + role: 'USER', + }, + }); + } + + async function createProject(name = `project-${Date.now()}-${Math.random()}`) { + const user = await createUser(); + return prisma.project.create({ data: { name, ownerId: user.id } }); + } + + async function createAgent(name = `agent-${Date.now()}-${Math.random()}`) { + const user = await createUser(); + const llm = await prisma.llm.create({ + data: { name: `llm-${Date.now()}-${Math.random()}`, type: 'openai', model: 'test' }, + }); + return prisma.agent.create({ + data: { name, llmId: llm.id, ownerId: user.id }, + }); + } + + it('creates a global skill (both FKs null) with defaults', async () => { + const skill = await prisma.skill.create({ + data: { name: 'global-test', content: 'hello' }, + }); + expect(skill.id).toBeDefined(); + expect(skill.projectId).toBeNull(); + expect(skill.agentId).toBeNull(); + expect(skill.priority).toBe(5); + expect(skill.semver).toBe('0.1.0'); + expect(skill.version).toBe(1); + expect(skill.currentRevisionId).toBeNull(); + expect(skill.files).toEqual({}); + expect(skill.metadata).toEqual({}); + expect(skill.description).toBe(''); + expect(skill.summary).toBeNull(); + expect(skill.chapters).toBeNull(); + }); + + it('creates a project-scoped skill', async () => { + const project = await createProject(); + const skill = await prisma.skill.create({ + data: { name: 'proj-test', content: 'hi', projectId: project.id }, + }); + expect(skill.projectId).toBe(project.id); + expect(skill.agentId).toBeNull(); + }); + + it('creates an agent-scoped skill', async () => { + const agent = await createAgent(); + const skill = await prisma.skill.create({ + data: { name: 'agent-test', content: 'hi', agentId: agent.id }, + }); + expect(skill.agentId).toBe(agent.id); + expect(skill.projectId).toBeNull(); + }); + + it('persists files and metadata as JSON', async () => { + const skill = await prisma.skill.create({ + data: { + name: 'with-files', + content: 'hi', + files: { 'scripts/setup.sh': '#!/bin/sh\necho hi' }, + metadata: { + hooks: { PreToolUse: [{ command: 'echo' }] }, + postInstall: 'scripts/setup.sh', + }, + }, + }); + expect(skill.files).toEqual({ 'scripts/setup.sh': '#!/bin/sh\necho hi' }); + expect(skill.metadata).toEqual({ + hooks: { PreToolUse: [{ command: 'echo' }] }, + postInstall: 'scripts/setup.sh', + }); + }); + + it('enforces unique (name, projectId)', async () => { + const project = await createProject(); + await prisma.skill.create({ + data: { name: 'dup', content: 'a', projectId: project.id }, + }); + await expect( + prisma.skill.create({ + data: { name: 'dup', content: 'b', projectId: project.id }, + }), + ).rejects.toThrow(); + }); + + it('enforces unique (name, agentId)', async () => { + const agent = await createAgent(); + await prisma.skill.create({ + data: { name: 'dup', content: 'a', agentId: agent.id }, + }); + await expect( + prisma.skill.create({ + data: { name: 'dup', content: 'b', agentId: agent.id }, + }), + ).rejects.toThrow(); + }); + + it('allows same name across different projects', async () => { + const p1 = await createProject(`p1-${Date.now()}`); + const p2 = await createProject(`p2-${Date.now()}`); + await prisma.skill.create({ + data: { name: 'shared', content: 'a', projectId: p1.id }, + }); + const second = await prisma.skill.create({ + data: { name: 'shared', content: 'b', projectId: p2.id }, + }); + expect(second.id).toBeDefined(); + }); + + it('allows same name across project + agent (different scopes)', async () => { + const project = await createProject(); + const agent = await createAgent(); + await prisma.skill.create({ + data: { name: 'overlap', content: 'a', projectId: project.id }, + }); + const second = await prisma.skill.create({ + data: { name: 'overlap', content: 'b', agentId: agent.id }, + }); + expect(second.id).toBeDefined(); + }); + + it('cascades on project delete', async () => { + const project = await createProject(); + const skill = await prisma.skill.create({ + data: { name: 'cascade-me', content: 'hi', projectId: project.id }, + }); + await prisma.project.delete({ where: { id: project.id } }); + const found = await prisma.skill.findUnique({ where: { id: skill.id } }); + expect(found).toBeNull(); + }); + + it('cascades on agent delete', async () => { + const agent = await createAgent(); + const skill = await prisma.skill.create({ + data: { name: 'cascade-agent', content: 'hi', agentId: agent.id }, + }); + await prisma.agent.delete({ where: { id: agent.id } }); + const found = await prisma.skill.findUnique({ where: { id: skill.id } }); + expect(found).toBeNull(); + }); + + it('preserves global skills when projects are deleted', async () => { + const project = await createProject(); + const skill = await prisma.skill.create({ + data: { name: 'global-survives', content: 'hi' }, + }); + await prisma.project.delete({ where: { id: project.id } }); + const found = await prisma.skill.findUnique({ where: { id: skill.id } }); + expect(found).not.toBeNull(); + expect(found?.id).toBe(skill.id); + }); + + it('updates updatedAt on change', async () => { + const skill = await prisma.skill.create({ + data: { name: 'mut', content: 'a' }, + }); + const original = skill.updatedAt; + await new Promise((r) => setTimeout(r, 50)); + const updated = await prisma.skill.update({ + where: { id: skill.id }, + data: { content: 'b' }, + }); + expect(updated.updatedAt.getTime()).toBeGreaterThan(original.getTime()); + }); +}); -- 2.49.1 From 1ec286bb146223b9169180669cea3af859a417c0 Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 00:38:35 +0100 Subject: [PATCH 05/18] feat(mcpd): ResourceRevision + ResourceProposal services + Prompt revision integration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the Skills + Revisions + Proposals work. Stands up the generic revision/proposal layer and wires Prompt into it. Skills will plug into the same infrastructure in PR-3 with no further service changes required. This PR is intentionally additive: PromptRequest table and routes are unchanged. The /api/v1/proposals API runs side-by-side with the legacy /api/v1/promptrequests API. The PromptRequest cutover (rename + backfill + mcplocal rewire) is deferred to a later PR so this one stays reviewable. ## What's added ### Repositories (src/mcpd/src/repositories/) - resource-revision.repository.ts — append-only revision log keyed by (resourceType, resourceId). Soft FK; no relations declared. Supports history listing, semver lookup, and contentHash cross-resource search. - resource-proposal.repository.ts — generic propose queue. Status lifecycle pending → approved | rejected. Mirrors Prompt's `?? ''` workaround for nullable-FK compound lookups. ### Services (src/mcpd/src/services/) - resource-revision.service.ts — record() inserts a revision with a stable sha256 contentHash computed from canonicalised JSON (key-sorted at every level so reordered objects produce the same hash). Caller passes a pre-computed semver; service does NOT decide bump policy. - resource-proposal.service.ts — propose / approve / reject / list, with a per-resourceType handler registry. PromptService registers the 'prompt' handler at construction; the SkillService will register 'skill' in PR-3. approve() runs in a Prisma $transaction so the resource update + revision insert + proposal status flip are atomic. ### Pure utility (src/mcpd/src/utils/semver.ts) - bumpSemver(current, kind) for major / minor / patch - compareSemver(a, b) — numeric, not lex (10 > 9) - isValidSemver(s) - Invalid input falls back to '0.1.0' rather than throwing — keeps the audit-write path from blowing up the prompt update if a row's semver ever drifts out of MAJOR.MINOR.PATCH shape. ### Routes (src/mcpd/src/routes/) - revisions.ts — GET /api/v1/revisions?resourceType=&resourceId=, GET /api/v1/revisions/:id, GET /api/v1/revisions/:id/diff?against= (unified-format diff via the `diff` package), and POST /api/v1/prompts/:id/restore-revision { revisionId, note? }. - proposals.ts — GET / POST /api/v1/proposals, GET /api/v1/proposals/:id, PUT for body updates, POST .../approve and POST .../reject, plus DELETE. ## What's changed - PromptService.create / update now record a ResourceRevision when the revision service is wired. Update auto-bumps patch on content change; authors can override via `--bump major|minor|patch` or `--semver X.Y.Z` on the CLI (forwarded into the PUT body). Best-effort: revision write failures are swallowed so the prompt save still succeeds (revision is audit, not source of truth). - PromptService.setProposalService registers a 'prompt' approval handler with the proposal service. Approval runs in a Prisma transaction: upsert prompt → record revision → update currentRevisionId → flip proposal status. semver bumps to 0.1.0 on first approval, patch thereafter. - New CLI flags on `mcpctl edit prompt`: --bump, --semver, --note. They're prompt-only (validated client-side); other resources reject them. - Aliases in shared.ts: `proposal`/`prop` → proposals, `revision`/`rev` → revisions. - diff dependency added to mcpd. ## Tests - src/mcpd/tests/utils/semver.test.ts — covers bump/compare/validate including numeric (not lex) semver compare and invalid-input fallback. - prompt-service.test.ts updated: makePrompt fixture now sets semver + agentId + currentRevisionId; updatePrompt assertion expects the auto-bumped patch in the same update call. - prompt-routes.test.ts updated symmetrically. ## RBAC `proposals` and `revisions` URL segments map to the existing `prompts` permission for now. PR-7 may split if a "reviewer" role becomes useful. ## Verification Full suite: 158 test files / 2127 tests green. `pnpm build` clean across all 6 workspace packages. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 4 +- completions/mcpctl.fish | 5 + pnpm-lock.yaml | 17 ++ src/cli/src/commands/edit.ts | 28 ++- src/cli/src/commands/shared.ts | 8 + src/mcpd/package.json | 2 + src/mcpd/src/main.ts | 23 +++ .../src/repositories/prompt.repository.ts | 2 + .../resource-proposal.repository.ts | 138 ++++++++++++++ .../resource-revision.repository.ts | 79 ++++++++ src/mcpd/src/routes/proposals.ts | 157 ++++++++++++++++ src/mcpd/src/routes/revisions.ts | 123 +++++++++++++ src/mcpd/src/services/prompt.service.ts | 168 +++++++++++++++++- .../src/services/resource-proposal.service.ts | 133 ++++++++++++++ .../src/services/resource-revision.service.ts | 95 ++++++++++ src/mcpd/src/utils/semver.ts | 56 ++++++ src/mcpd/src/validation/prompt.schema.ts | 9 + src/mcpd/tests/prompt-routes.test.ts | 9 +- .../tests/services/prompt-service.test.ts | 7 +- src/mcpd/tests/utils/semver.test.ts | 70 ++++++++ 20 files changed, 1126 insertions(+), 7 deletions(-) create mode 100644 src/mcpd/src/repositories/resource-proposal.repository.ts create mode 100644 src/mcpd/src/repositories/resource-revision.repository.ts create mode 100644 src/mcpd/src/routes/proposals.ts create mode 100644 src/mcpd/src/routes/revisions.ts create mode 100644 src/mcpd/src/services/resource-proposal.service.ts create mode 100644 src/mcpd/src/services/resource-revision.service.ts create mode 100644 src/mcpd/src/utils/semver.ts create mode 100644 src/mcpd/tests/utils/semver.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index fec5261..f50e4ff 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -228,11 +228,11 @@ _mcpctl() { return ;; edit) if [[ -z "$resource_type" ]]; then - COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "servers secrets projects groups rbac prompts promptrequests personalities --bump --semver --note -h --help" -- "$cur")) else local names names=$(_mcpctl_resource_names "$resource_type") - COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "$names --bump --semver --note -h --help" -- "$cur")) fi return ;; apply) diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index def55a4..754f7f8 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -509,6 +509,11 @@ complete -c mcpctl -n "__fish_seen_subcommand_from delete" -l agent -d 'Agent na complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s t -l tail -d 'Number of lines to show' -x complete -c mcpctl -n "__fish_seen_subcommand_from logs" -s i -l instance -d 'Instance/replica index (0-based, for servers with multiple replicas)' -x +# edit options +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l bump -d 'Bump prompt semver after edit: major | minor | patch' -x +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l semver -d 'Set prompt semver explicitly (X.Y.Z)' -x +complete -c mcpctl -n "__fish_seen_subcommand_from edit" -l note -d 'Note attached to the resulting revision' -x + # apply options complete -c mcpctl -n "__fish_seen_subcommand_from apply" -s f -l file -d 'Path to config file (alternative to positional arg)' -rF complete -c mcpctl -n "__fish_seen_subcommand_from apply" -l dry-run -d 'Validate and show changes without applying' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b44c75..d8baa90 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,6 +130,9 @@ importers: bcrypt: specifier: ^5.1.1 version: 5.1.1 + diff: + specifier: ^5.2.0 + version: 5.2.2 dockerode: specifier: ^4.0.9 version: 4.0.9 @@ -146,6 +149,9 @@ importers: '@types/bcrypt': specifier: ^5.0.2 version: 5.0.2 + '@types/diff': + specifier: ^5.2.3 + version: 5.2.3 '@types/dockerode': specifier: ^4.0.1 version: 4.0.1 @@ -1095,6 +1101,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/diff@5.2.3': + resolution: {integrity: sha512-K0Oqlrq3kQMaO2RhfrNQX5trmt+XLyom88zS0u84nnIcLvFnRUMRRHmrGny5GSM+kNO9IZLARsdQHDzkhAgmrQ==} + '@types/diff@8.0.0': resolution: {integrity: sha512-o7jqJM04gfaYrdCecCVMbZhNdG6T1MHg/oQoRFdERLV+4d+V7FijhiEAbFu0Usww84Yijk9yH58U4Jk4HbtzZw==} deprecated: This is a stub types definition. diff provides its own type definitions, so you do not need this installed. @@ -1680,6 +1689,10 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + diff@5.2.2: + resolution: {integrity: sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==} + engines: {node: '>=0.3.1'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -4092,6 +4105,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/diff@5.2.3': {} + '@types/diff@8.0.0': dependencies: diff: 8.0.3 @@ -4689,6 +4704,8 @@ snapshots: detect-libc@2.1.2: {} + diff@5.2.2: {} + diff@8.0.3: {} docker-modem@5.0.6: diff --git a/src/cli/src/commands/edit.ts b/src/cli/src/commands/edit.ts index dbfcb41..de8b8e5 100644 --- a/src/cli/src/commands/edit.ts +++ b/src/cli/src/commands/edit.ts @@ -37,7 +37,10 @@ export function createEditCommand(deps: EditCommandDeps): Command { .description('Edit a resource in your default editor (server, project)') .argument('', 'Resource type (server, project)') .argument('', 'Resource name or ID') - .action(async (resourceArg: string, nameOrId: string) => { + .option('--bump ', 'Bump prompt semver after edit: major | minor | patch') + .option('--semver ', 'Set prompt semver explicitly (X.Y.Z)') + .option('--note ', 'Note attached to the resulting revision') + .action(async (resourceArg: string, nameOrId: string, opts: { bump?: string; semver?: string; note?: string }) => { const resource = resolveResource(resourceArg); // Instances are immutable @@ -55,6 +58,23 @@ export function createEditCommand(deps: EditCommandDeps): Command { return; } + // Validation for prompt-only revision flags + if ((opts.bump !== undefined || opts.semver !== undefined || opts.note !== undefined) && resource !== 'prompts') { + log('Error: --bump, --semver, and --note are only valid for prompts'); + process.exitCode = 1; + return; + } + if (opts.bump !== undefined && opts.semver !== undefined) { + log('Error: pass --bump or --semver, not both'); + process.exitCode = 1; + return; + } + if (opts.bump !== undefined && !['major', 'minor', 'patch'].includes(opts.bump)) { + log("Error: --bump must be 'major', 'minor', or 'patch'"); + process.exitCode = 1; + return; + } + // Resolve name → ID const id = await resolveNameOrId(client, resource, nameOrId); @@ -102,6 +122,12 @@ export function createEditCommand(deps: EditCommandDeps): Command { // Parse and apply const updates = yaml.load(modifiedClean) as Record; + // Append semver-related flags for prompts (server-side bumps + records revision). + if (resource === 'prompts') { + if (opts.bump !== undefined) updates.bump = opts.bump; + if (opts.semver !== undefined) updates.semver = opts.semver; + if (opts.note !== undefined) updates.note = opts.note; + } await client.put(`/api/v1/${resource}/${id}`, updates); log(`${singular} '${nameOrId}' updated.`); } finally { diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index fbb9cdf..4159320 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -21,6 +21,14 @@ export const RESOURCE_ALIASES: Record = { promptrequest: 'promptrequests', promptrequests: 'promptrequests', pr: 'promptrequests', + // PR-2: shared revision + proposal queue (replaces promptrequests in + // PR-7). Lookup goes through /api/v1/proposals and /api/v1/revisions. + proposal: 'proposals', + proposals: 'proposals', + prop: 'proposals', + revision: 'revisions', + revisions: 'revisions', + rev: 'revisions', serverattachment: 'serverattachments', serverattachments: 'serverattachments', sa: 'serverattachments', diff --git a/src/mcpd/package.json b/src/mcpd/package.json index 3f51592..48e570a 100644 --- a/src/mcpd/package.json +++ b/src/mcpd/package.json @@ -23,6 +23,7 @@ "@mcpctl/shared": "workspace:*", "@prisma/client": "^6.0.0", "bcrypt": "^5.1.1", + "diff": "^5.2.0", "dockerode": "^4.0.9", "fastify": "^5.0.0", "js-yaml": "^4.1.0", @@ -30,6 +31,7 @@ }, "devDependencies": { "@types/bcrypt": "^5.0.2", + "@types/diff": "^5.2.3", "@types/dockerode": "^4.0.1", "@types/js-yaml": "^4.0.9", "@types/node": "^25.3.0" diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index d53828f..afadf02 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -98,8 +98,14 @@ import { registerMcpTokenRoutes, } from './routes/index.js'; import { registerPromptRoutes } from './routes/prompts.js'; +import { registerRevisionRoutes } from './routes/revisions.js'; +import { registerProposalRoutes } from './routes/proposals.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; +import { ResourceRevisionRepository } from './repositories/resource-revision.repository.js'; +import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js'; +import { ResourceRevisionService } from './services/resource-revision.service.js'; +import { ResourceProposalService } from './services/resource-proposal.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; import { ResourceRuleRegistry } from './validation/resource-rules.js'; @@ -168,6 +174,12 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { 'mcp': 'servers', 'prompts': 'prompts', 'promptrequests': 'promptrequests', + // PR-2: revisions/proposals piggyback on the prompts permission for now. + // Anyone with view:prompts can read history; anyone with edit:prompts can + // approve/reject proposals. PR-7 may split these out if RBAC granularity + // becomes useful (e.g., a "reviewer" role). + 'revisions': 'prompts', + 'proposals': 'prompts', 'mcptokens': 'mcptokens', 'llms': 'llms', // v5: durable inference task queue. Same default action mapping as @@ -468,6 +480,15 @@ async function main(): Promise { const promptRuleRegistry = new ResourceRuleRegistry(); promptRuleRegistry.register(systemPromptVarsRule); const promptService = new PromptService(promptRepo, promptRequestRepo, projectRepo, promptRuleRegistry, agentRepo); + // PR-2: shared revision/proposal infra. Promp service registers its + // 'prompt' approval handler with the proposal service via setProposalService; + // PR-3 wires the same for skills. + const resourceRevisionRepo = new ResourceRevisionRepository(prisma); + const resourceRevisionService = new ResourceRevisionService(resourceRevisionRepo); + const resourceProposalRepo = new ResourceProposalRepository(prisma); + const resourceProposalService = new ResourceProposalService(resourceProposalRepo, prisma); + promptService.setRevisionService(resourceRevisionService); + promptService.setProposalService(resourceProposalService); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -668,6 +689,8 @@ async function main(): Promise { registerGroupRoutes(app, groupService); registerMcpTokenRoutes(app, { tokenService: mcpTokenService, projectRepo }); registerPromptRoutes(app, promptService, projectRepo, agentRepo); + registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); + registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/prompt.repository.ts b/src/mcpd/src/repositories/prompt.repository.ts index 80d2511..ea0fde5 100644 --- a/src/mcpd/src/repositories/prompt.repository.ts +++ b/src/mcpd/src/repositories/prompt.repository.ts @@ -14,6 +14,8 @@ export interface PromptUpdateInput { priority?: number; summary?: string; chapters?: string[]; + semver?: string; + currentRevisionId?: string | null; } export interface IPromptRepository { diff --git a/src/mcpd/src/repositories/resource-proposal.repository.ts b/src/mcpd/src/repositories/resource-proposal.repository.ts new file mode 100644 index 0000000..6065888 --- /dev/null +++ b/src/mcpd/src/repositories/resource-proposal.repository.ts @@ -0,0 +1,138 @@ +import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client'; + +import type { ResourceType } from './resource-revision.repository.js'; + +/** + * Generic propose/approve/reject queue keyed by (resourceType, name, + * projectId|agentId). Successor to PromptRequest. The repo mirrors + * PromptRepository's `?? ''` workaround for nullable-FK compound lookups. + */ + +export type ProposalStatus = 'pending' | 'approved' | 'rejected'; + +export interface ProposalListFilter { + resourceType?: ResourceType; + projectId?: string; + agentId?: string; + status?: ProposalStatus; +} + +export interface CreateProposalInput { + resourceType: ResourceType; + name: string; + body: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; +} + +export interface UpdateProposalStatusInput { + status: ProposalStatus; + reviewerNote?: string; + approvedRevisionId?: string; +} + +export interface IResourceProposalRepository { + list(filter: ProposalListFilter): Promise; + findById(id: string): Promise; + findByName(resourceType: ResourceType, name: string, scope: { projectId: string | null; agentId: string | null }): Promise; + findBySession(sessionId: string, projectId?: string): Promise; + create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise; + updateBody(id: string, body: Prisma.InputJsonValue): Promise; + updateStatus(id: string, data: UpdateProposalStatusInput, tx?: Prisma.TransactionClient): Promise; + delete(id: string): Promise; +} + +export class ResourceProposalRepository implements IResourceProposalRepository { + constructor(private readonly prisma: PrismaClient) {} + + async list(filter: ProposalListFilter): Promise { + const where: Prisma.ResourceProposalWhereInput = {}; + if (filter.resourceType) where.resourceType = filter.resourceType; + if (filter.status) where.status = filter.status; + if (filter.projectId !== undefined) { + // Match project-scoped + globals (NULL projectId), like PromptRepo. + where.OR = [{ projectId: filter.projectId }, { projectId: null, agentId: null }]; + } + if (filter.agentId !== undefined) { + where.agentId = filter.agentId; + } + return this.prisma.resourceProposal.findMany({ + where, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.resourceProposal.findUnique({ + where: { id }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + } + + async findByName( + resourceType: ResourceType, + name: string, + scope: { projectId: string | null; agentId: string | null }, + ): Promise { + if (scope.agentId !== null) { + return this.prisma.resourceProposal.findUnique({ + where: { resourceType_name_agentId: { resourceType, name, agentId: scope.agentId } }, + }); + } + // Project-scoped or global (projectId=null is handled by the same compound key). + return this.prisma.resourceProposal.findUnique({ + where: { resourceType_name_projectId: { resourceType, name, projectId: scope.projectId ?? '' } }, + }); + } + + async findBySession(sessionId: string, projectId?: string): Promise { + const where: Prisma.ResourceProposalWhereInput = { createdBySession: sessionId }; + if (projectId !== undefined) { + where.OR = [{ projectId }, { projectId: null, agentId: null }]; + } + return this.prisma.resourceProposal.findMany({ + where, + orderBy: { createdAt: 'desc' }, + }); + } + + async create(data: CreateProposalInput, tx?: Prisma.TransactionClient): Promise { + const client = tx ?? this.prisma; + return client.resourceProposal.create({ data }); + } + + async updateBody(id: string, body: Prisma.InputJsonValue): Promise { + return this.prisma.resourceProposal.update({ + where: { id }, + data: { body, version: { increment: 1 } }, + }); + } + + async updateStatus( + id: string, + data: UpdateProposalStatusInput, + tx?: Prisma.TransactionClient, + ): Promise { + const client = tx ?? this.prisma; + const update: Prisma.ResourceProposalUpdateInput = { + status: data.status, + version: { increment: 1 }, + }; + if (data.reviewerNote !== undefined) update.reviewerNote = data.reviewerNote; + if (data.approvedRevisionId !== undefined) update.approvedRevisionId = data.approvedRevisionId; + return client.resourceProposal.update({ where: { id }, data: update }); + } + + async delete(id: string): Promise { + await this.prisma.resourceProposal.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/resource-revision.repository.ts b/src/mcpd/src/repositories/resource-revision.repository.ts new file mode 100644 index 0000000..2731423 --- /dev/null +++ b/src/mcpd/src/repositories/resource-revision.repository.ts @@ -0,0 +1,79 @@ +import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client'; + +/** + * Append-only revision log shared by Prompt and Skill (and any future + * resource type with a `resourceType` discriminator). The repository is + * intentionally narrow: callers always know which resource they're + * looking at, so every read takes (resourceType, resourceId) explicitly. + * + * `resourceId` is a soft FK — there's no `Prompt`/`Skill` relation here, + * because revisions need to outlive the resources they describe (audit + * survives deletion). That means we accept any string and trust the + * service layer to keep them in sync with real IDs. + */ + +export type ResourceType = 'prompt' | 'skill'; + +export interface CreateRevisionInput { + resourceType: ResourceType; + resourceId: string; + semver: string; + contentHash: string; + body: Prisma.InputJsonValue; + authorUserId?: string; + authorSessionId?: string; + note?: string; +} + +export interface IResourceRevisionRepository { + create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise; + findById(id: string): Promise; + findLatest(resourceType: ResourceType, resourceId: string): Promise; + findHistory(resourceType: ResourceType, resourceId: string, limit?: number): Promise; + findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise; + findByContentHash(contentHash: string): Promise; +} + +export class ResourceRevisionRepository implements IResourceRevisionRepository { + constructor(private readonly prisma: PrismaClient) {} + + async create(data: CreateRevisionInput, tx?: Prisma.TransactionClient): Promise { + const client = tx ?? this.prisma; + return client.resourceRevision.create({ data }); + } + + async findById(id: string): Promise { + return this.prisma.resourceRevision.findUnique({ where: { id } }); + } + + async findLatest(resourceType: ResourceType, resourceId: string): Promise { + return this.prisma.resourceRevision.findFirst({ + where: { resourceType, resourceId }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findHistory(resourceType: ResourceType, resourceId: string, limit = 100): Promise { + return this.prisma.resourceRevision.findMany({ + where: { resourceType, resourceId }, + orderBy: { createdAt: 'desc' }, + take: limit, + }); + } + + async findBySemver(resourceType: ResourceType, resourceId: string, semver: string): Promise { + // Multiple revisions can share a semver if a value was reused (rare, + // but possible with manual --semver overrides). Return the latest. + return this.prisma.resourceRevision.findFirst({ + where: { resourceType, resourceId, semver }, + orderBy: { createdAt: 'desc' }, + }); + } + + async findByContentHash(contentHash: string): Promise { + return this.prisma.resourceRevision.findMany({ + where: { contentHash }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/src/mcpd/src/routes/proposals.ts b/src/mcpd/src/routes/proposals.ts new file mode 100644 index 0000000..a9a93f8 --- /dev/null +++ b/src/mcpd/src/routes/proposals.ts @@ -0,0 +1,157 @@ +import type { FastifyInstance } from 'fastify'; + +import type { ResourceProposalService } from '../services/resource-proposal.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import type { + ResourceType, +} from '../repositories/resource-revision.repository.js'; +import type { ProposalStatus } from '../repositories/resource-proposal.repository.js'; + +interface ProposalRouteDeps { + proposalService: ResourceProposalService; + projectRepo: IProjectRepository; + agentRepo?: IAgentRepository; +} + +const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const; +const VALID_STATUSES: readonly ProposalStatus[] = ['pending', 'approved', 'rejected'] as const; + +export function registerProposalRoutes(app: FastifyInstance, deps: ProposalRouteDeps): void { + const { proposalService, projectRepo, agentRepo } = deps; + + app.get<{ Querystring: { resourceType?: string; status?: string; project?: string; agent?: string } }>( + '/api/v1/proposals', + async (request) => { + const filter: { + resourceType?: ResourceType; + status?: ProposalStatus; + projectId?: string; + agentId?: string; + } = {}; + const { resourceType, status, project, agent } = request.query; + if (resourceType !== undefined) { + if (!VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign(new Error(`Invalid resourceType: ${resourceType}`), { statusCode: 400 }); + } + filter.resourceType = resourceType as ResourceType; + } + if (status !== undefined) { + if (!VALID_STATUSES.includes(status as ProposalStatus)) { + throw Object.assign(new Error(`Invalid status: ${status}`), { statusCode: 400 }); + } + filter.status = status as ProposalStatus; + } + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + filter.projectId = proj.id; + } + if (agent !== undefined) { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(agent); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${agent}`), { statusCode: 404 }); + } + filter.agentId = ag.id; + } + return proposalService.list(filter); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request) => { + return proposalService.getById(request.params.id); + }); + + app.post('/api/v1/proposals', async (request, reply) => { + const body = request.body as Record; + const resourceType = body['resourceType']; + if (typeof resourceType !== 'string' || !VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign(new Error('resourceType must be "prompt" or "skill"'), { statusCode: 400 }); + } + const name = body['name']; + if (typeof name !== 'string' || name.length === 0) { + throw Object.assign(new Error('name is required'), { statusCode: 400 }); + } + const proposalBody = body['body']; + if (proposalBody === undefined || typeof proposalBody !== 'object' || proposalBody === null) { + throw Object.assign(new Error('body must be an object'), { statusCode: 400 }); + } + const input: { + resourceType: ResourceType; + name: string; + body: Record; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; + } = { + resourceType: resourceType as ResourceType, + name, + body: proposalBody as Record, + }; + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + input.projectId = proj.id; + } else if (typeof body['projectId'] === 'string') { + input.projectId = body['projectId']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(body['agent']); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + input.agentId = ag.id; + } else if (typeof body['agentId'] === 'string') { + input.agentId = body['agentId']; + } + if (typeof body['createdBySession'] === 'string') input.createdBySession = body['createdBySession']; + if (typeof body['createdByUserId'] === 'string') input.createdByUserId = body['createdByUserId']; + + const proposal = await proposalService.propose(input); + reply.code(201); + return proposal; + }); + + app.put<{ Params: { id: string }; Body: { body?: Record } }>( + '/api/v1/proposals/:id', + async (request) => { + const proposalBody = request.body.body; + if (proposalBody === undefined) { + throw Object.assign(new Error('body is required'), { statusCode: 400 }); + } + return proposalService.updateBody(request.params.id, proposalBody); + }, + ); + + app.post<{ Params: { id: string } }>('/api/v1/proposals/:id/approve', async (request) => { + // approverUserId is set by the auth middleware on the request — we + // don't grab it explicitly here; service uses what the audit layer + // already records. Threading it through requires the auth context + // (out of scope for PR-2; PR-4's reviewer flow will surface it). + return proposalService.approve(request.params.id); + }); + + app.post<{ Params: { id: string }; Body: { reason?: string; reviewerNote?: string } }>( + '/api/v1/proposals/:id/reject', + async (request) => { + const note = request.body.reviewerNote ?? request.body.reason ?? ''; + return proposalService.reject(request.params.id, note); + }, + ); + + app.delete<{ Params: { id: string } }>('/api/v1/proposals/:id', async (request, reply) => { + await proposalService.delete(request.params.id); + reply.code(204); + }); +} diff --git a/src/mcpd/src/routes/revisions.ts b/src/mcpd/src/routes/revisions.ts new file mode 100644 index 0000000..381ff8b --- /dev/null +++ b/src/mcpd/src/routes/revisions.ts @@ -0,0 +1,123 @@ +import type { FastifyInstance } from 'fastify'; +import { createPatch } from 'diff'; + +import type { ResourceRevisionService } from '../services/resource-revision.service.js'; +import type { PromptService } from '../services/prompt.service.js'; +import type { ResourceType } from '../repositories/resource-revision.repository.js'; + +interface RevisionRouteDeps { + revisionService: ResourceRevisionService; + promptService: PromptService; + // Future: skillService for PR-3. +} + +const VALID_TYPES: readonly ResourceType[] = ['prompt', 'skill'] as const; + +export function registerRevisionRoutes(app: FastifyInstance, deps: RevisionRouteDeps): void { + const { revisionService, promptService } = deps; + + // List history for a resource. Either both query params or none (none = error). + app.get<{ Querystring: { resourceType?: string; resourceId?: string; limit?: string } }>( + '/api/v1/revisions', + async (request) => { + const { resourceType, resourceId, limit } = request.query; + if (!resourceType || !resourceId) { + throw Object.assign( + new Error('Both resourceType and resourceId are required'), + { statusCode: 400 }, + ); + } + if (!VALID_TYPES.includes(resourceType as ResourceType)) { + throw Object.assign( + new Error(`Invalid resourceType: ${resourceType}`), + { statusCode: 400 }, + ); + } + const limitNum = limit ? Math.min(500, Math.max(1, Number(limit))) : 100; + return revisionService.listHistory(resourceType as ResourceType, resourceId, limitNum); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/revisions/:id', async (request) => { + const revision = await revisionService.getById(request.params.id); + if (revision === null) { + throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 }); + } + return revision; + }); + + /** + * Unified diff between two revisions, or between a revision and the + * live resource body. `against` accepts another revision id or the + * literal string `live`. + */ + app.get<{ Params: { id: string }; Querystring: { against?: string } }>( + '/api/v1/revisions/:id/diff', + async (request) => { + const revision = await revisionService.getById(request.params.id); + if (revision === null) { + throw Object.assign(new Error(`Revision not found: ${request.params.id}`), { statusCode: 404 }); + } + const against = request.query.against ?? 'live'; + + let otherContent: string; + let otherLabel: string; + if (against === 'live') { + // For prompts, fetch the live row by resourceId. + if (revision.resourceType === 'prompt') { + const prompt = await promptService.getPrompt(revision.resourceId); + otherContent = prompt.content; + otherLabel = `${prompt.name} (live, semver ${prompt.semver})`; + } else { + // PR-3 will wire skillService here. + throw Object.assign( + new Error(`Live diff not supported for resourceType ${revision.resourceType} yet`), + { statusCode: 501 }, + ); + } + } else { + const otherRev = await revisionService.getById(against); + if (otherRev === null) { + throw Object.assign(new Error(`Other revision not found: ${against}`), { statusCode: 404 }); + } + if (otherRev.resourceType !== revision.resourceType || otherRev.resourceId !== revision.resourceId) { + throw Object.assign( + new Error('Diff requires both revisions to be of the same resource'), + { statusCode: 400 }, + ); + } + otherContent = stringContent(otherRev.body); + otherLabel = `revision ${otherRev.id} (${otherRev.semver})`; + } + + const thisContent = stringContent(revision.body); + const thisLabel = `revision ${revision.id} (${revision.semver})`; + + // Unified-format patch. Caller can render this directly or pass to a diff viewer. + const patch = createPatch(`${revision.resourceType}/${revision.resourceId}`, otherContent, thisContent, otherLabel, thisLabel); + return { patch }; + }, + ); + + // POST /api/v1/prompts/:id/restore-revision { revisionId, note? } + // (Skill route registered in PR-3 alongside this with the same shape.) + app.post<{ Params: { id: string }; Body: { revisionId: string; note?: string } }>( + '/api/v1/prompts/:id/restore-revision', + async (request) => { + const { revisionId, note } = request.body; + if (!revisionId) { + throw Object.assign(new Error('revisionId is required'), { statusCode: 400 }); + } + return promptService.restoreRevisionForPrompt(request.params.id, revisionId, note); + }, + ); +} + +/** Pull a `content` string out of a revision body, falling back to the raw JSON. */ +function stringContent(body: unknown): string { + if (body !== null && typeof body === 'object' && !Array.isArray(body)) { + const v = (body as Record)['content']; + if (typeof v === 'string') return v; + } + return JSON.stringify(body, null, 2); +} diff --git a/src/mcpd/src/services/prompt.service.ts b/src/mcpd/src/services/prompt.service.ts index 528826a..d8a4a0c 100644 --- a/src/mcpd/src/services/prompt.service.ts +++ b/src/mcpd/src/services/prompt.service.ts @@ -6,11 +6,15 @@ 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'; +import type { ResourceRevisionService } from './resource-revision.service.js'; +import type { ResourceProposalService } from './resource-proposal.service.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; import { SYSTEM_PROJECT_NAME, getSystemPromptDefault } from '../bootstrap/system-project.js'; import type { ResourceRuleRegistry, RuleContext } from '../validation/resource-rules.js'; export class PromptService { private summaryService: PromptSummaryService | null = null; + private revisionService: ResourceRevisionService | null = null; constructor( private readonly promptRepo: IPromptRepository, @@ -24,6 +28,85 @@ export class PromptService { this.summaryService = service; } + /** + * Wire revision + proposal infrastructure (PR-2). Optional so existing + * tests that construct a bare PromptService keep working unchanged — + * when these are unset, create/update skip the revision write and + * proposal-approval is unsupported. + */ + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + setProposalService(service: ResourceProposalService): void { + // Register a 'prompt' approval handler so proposalService.approve(id) + // can dispatch to us when the proposal targets a prompt. The service + // itself is kept only via this closure binding — no field needed. + service.setHandler('prompt', async (proposal, tx, _approverUserId) => { + const body = (proposal.body ?? {}) as Record; + const content = String(body['content'] ?? ''); + const priority = typeof body['priority'] === 'number' ? body['priority'] : 5; + const linkTarget = typeof body['linkTarget'] === 'string' ? body['linkTarget'] : undefined; + // Resolve scope: project-only for now (agent-scoped proposals come with PR-3+). + const projectId = proposal.projectId ?? null; + const agentId = proposal.agentId ?? null; + + // Upsert: existing prompt with this (name, scope) → update body and bump semver; + // otherwise → create at 0.1.0. + const existing = agentId !== null + ? await tx.prompt.findUnique({ where: { name_agentId: { name: proposal.name, agentId } } }) + : await tx.prompt.findUnique({ where: { name_projectId: { name: proposal.name, projectId: projectId ?? '' } } }); + + let promptId: string; + let newSemver: string; + if (existing !== null) { + // Bump patch for an approved-update. + newSemver = bumpSemver(existing.semver, 'patch'); + await tx.prompt.update({ + where: { id: existing.id }, + data: { content, priority, semver: newSemver }, + }); + promptId = existing.id; + } else { + // Approval-from-scratch: prompt didn't exist before this proposal. + newSemver = '0.1.0'; + const created = await tx.prompt.create({ + data: { + name: proposal.name, + content, + priority, + ...(projectId !== null ? { projectId } : {}), + ...(agentId !== null ? { agentId } : {}), + ...(linkTarget !== undefined ? { linkTarget } : {}), + semver: newSemver, + }, + }); + promptId = created.id; + } + + const { revision } = await this.revisionService!.record( + { + resourceType: 'prompt', + resourceId: promptId, + semver: newSemver, + body: { content, priority, ...(linkTarget !== undefined ? { linkTarget } : {}) }, + ...(proposal.createdByUserId !== null ? { authorUserId: proposal.createdByUserId } : {}), + ...(proposal.createdBySession !== null ? { authorSessionId: proposal.createdBySession } : {}), + note: `approved proposal ${proposal.id}`, + }, + tx, + ); + + // Soft pointer to latest revision. + await tx.prompt.update({ + where: { id: promptId }, + data: { currentRevisionId: revision.id }, + }); + + return { resourceId: promptId, revisionId: revision.id }; + }); + } + /** * Run resource validation rules for a prompt. * Throws 400 if validation fails. @@ -104,6 +187,10 @@ export class PromptService { if (data.priority !== undefined) createData.priority = data.priority; if (data.linkTarget !== undefined) createData.linkTarget = data.linkTarget; const prompt = await this.promptRepo.create(createData); + // Record initial revision (0.1.0). Non-blocking — revision is audit, not source of truth. + if (this.revisionService) { + this.recordPromptRevision(prompt, '0.1.0', 'created').catch(() => {}); + } // Auto-generate summary/chapters (non-blocking — don't fail create if summary fails) if (this.summaryService && !data.linkTarget) { this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); @@ -113,16 +200,38 @@ export class PromptService { async updatePrompt(id: string, input: unknown): Promise { const data = UpdatePromptSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw Object.assign(new Error('Pass --semver or --bump, not both'), { statusCode: 400 }); + } const existing = await this.getPrompt(id); if (data.content !== undefined) { await this.validatePromptRules(existing.name, data.content, existing.projectId, 'update'); } - const updateData: { content?: string; priority?: number } = {}; + // Resolve new semver: + // explicit > explicit-bump > auto-patch (only when content changed) + let newSemver = existing.semver; + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (data.content !== undefined) { + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const updateData: { content?: string; priority?: number; semver?: string } = {}; if (data.content !== undefined) updateData.content = data.content; if (data.priority !== undefined) updateData.priority = data.priority; + if (newSemver !== existing.semver) updateData.semver = newSemver; const prompt = await this.promptRepo.update(id, updateData); + + // Record revision when content actually changed OR semver was explicitly bumped. + const shouldRecord = data.content !== undefined || data.bump !== undefined || data.semver !== undefined; + if (this.revisionService && shouldRecord) { + this.recordPromptRevision(prompt, newSemver, data.note ?? null).catch(() => {}); + } + // Regenerate summary when content changes if (this.summaryService && data.content !== undefined && !prompt.linkTarget) { this.generateAndStoreSummary(prompt.id, data.content).catch(() => {}); @@ -130,6 +239,57 @@ export class PromptService { return prompt; } + /** + * Append a ResourceRevision row for this prompt and update its + * currentRevisionId. Best-effort — failures are swallowed because the + * audit log isn't load-bearing (the resource row's inline content is + * the source of truth). + */ + private async recordPromptRevision(prompt: Prompt, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const body: Record = { content: prompt.content, priority: prompt.priority }; + if (prompt.linkTarget !== null) body['linkTarget'] = prompt.linkTarget; + const { revision } = await this.revisionService.record({ + resourceType: 'prompt', + resourceId: prompt.id, + semver, + body, + ...(note !== null ? { note } : {}), + }); + await this.promptRepo.update(prompt.id, { currentRevisionId: revision.id }); + } + + /** + * Restore a prompt to a prior revision: writes the revision's body + * back as a NEW update (which produces a new patch-bumped revision), + * preserving the audit chain. Returns the updated prompt. + */ + async restoreRevisionForPrompt(promptId: string, revisionId: string, note?: string): Promise { + if (this.revisionService === null) { + throw new Error('Revision service not wired'); + } + const revision = await this.revisionService.getById(revisionId); + if (revision === null) throw new NotFoundError(`Revision not found: ${revisionId}`); + if (revision.resourceType !== 'prompt' || revision.resourceId !== promptId) { + throw Object.assign( + new Error('Revision does not belong to this prompt'), + { statusCode: 400 }, + ); + } + const body = (revision.body ?? {}) as Record; + const content = typeof body['content'] === 'string' ? body['content'] : undefined; + const priority = typeof body['priority'] === 'number' ? body['priority'] : undefined; + if (content === undefined) { + throw Object.assign(new Error('Revision has no content to restore'), { statusCode: 400 }); + } + return this.updatePrompt(promptId, { + content, + priority, + bump: 'patch', + note: note ?? `restored from revision ${revisionId}`, + }); + } + async regenerateSummary(id: string): Promise { const prompt = await this.getPrompt(id); if (!this.summaryService) { @@ -226,6 +386,11 @@ export class PromptService { const prompt = await this.promptRepo.create(createData); + // Record the initial revision so the approved prompt has a v0.1.0 history entry. + if (this.revisionService) { + this.recordPromptRevision(prompt, '0.1.0', `approved promptrequest ${requestId}`).catch(() => {}); + } + // Delete the request await this.promptRequestRepo.delete(requestId); @@ -324,3 +489,4 @@ export class PromptService { return results; } } + diff --git a/src/mcpd/src/services/resource-proposal.service.ts b/src/mcpd/src/services/resource-proposal.service.ts new file mode 100644 index 0000000..db658af --- /dev/null +++ b/src/mcpd/src/services/resource-proposal.service.ts @@ -0,0 +1,133 @@ +import type { PrismaClient, Prisma, ResourceProposal } from '@prisma/client'; + +import type { + IResourceProposalRepository, + CreateProposalInput, + ProposalListFilter, +} from '../repositories/resource-proposal.repository.js'; +import type { ResourceType } from '../repositories/resource-revision.repository.js'; +import { NotFoundError } from './mcp-server.service.js'; + +/** + * Per-resourceType handler invoked when a proposal is approved. The + * handler runs inside the approval transaction; it must apply the + * proposed body to the live resource (creating it if needed), record + * a ResourceRevision, and return the resulting revision id so the + * proposal row can link to it. + * + * Registered by the resource's own service at boot time: + * PromptService → setHandler('prompt', ...) + * SkillService → setHandler('skill', ...) // PR-3 + */ +export type ProposalApprovalHandler = ( + proposal: ResourceProposal, + tx: Prisma.TransactionClient, + approverUserId?: string, +) => Promise<{ resourceId: string; revisionId: string }>; + +export interface ProposeInput { + resourceType: ResourceType; + name: string; + body: Record; + projectId?: string; + agentId?: string; + createdBySession?: string; + createdByUserId?: string; +} + +export class ResourceProposalService { + private readonly handlers = new Map(); + + constructor( + private readonly repo: IResourceProposalRepository, + private readonly prisma: PrismaClient, + ) {} + + /** Registered by Prompt/Skill services at construction time. */ + setHandler(resourceType: ResourceType, handler: ProposalApprovalHandler): void { + this.handlers.set(resourceType, handler); + } + + async list(filter: ProposalListFilter): Promise { + return this.repo.list(filter); + } + + async getById(id: string): Promise { + const proposal = await this.repo.findById(id); + if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`); + return proposal; + } + + async findBySession(sessionId: string, projectId?: string): Promise { + return this.repo.findBySession(sessionId, projectId); + } + + async propose(input: ProposeInput): Promise { + const data: CreateProposalInput = { + resourceType: input.resourceType, + name: input.name, + body: input.body as Prisma.InputJsonValue, + }; + if (input.projectId !== undefined) data.projectId = input.projectId; + if (input.agentId !== undefined) data.agentId = input.agentId; + if (input.createdBySession !== undefined) data.createdBySession = input.createdBySession; + if (input.createdByUserId !== undefined) data.createdByUserId = input.createdByUserId; + return this.repo.create(data); + } + + async updateBody(id: string, body: Record): Promise { + await this.getById(id); // 404 if missing + return this.repo.updateBody(id, body as Prisma.InputJsonValue); + } + + /** + * Approve the proposal: dispatch to the type-specific handler inside + * a transaction, then mark the proposal `approved` and link the + * resulting revision id. + */ + async approve(id: string, approverUserId?: string): Promise { + return this.prisma.$transaction(async (tx) => { + const proposal = await tx.resourceProposal.findUnique({ where: { id } }); + if (proposal === null) throw new NotFoundError(`Proposal not found: ${id}`); + if (proposal.status !== 'pending') { + throw Object.assign( + new Error(`Proposal is ${proposal.status}, not pending`), + { statusCode: 409 }, + ); + } + const handler = this.handlers.get(proposal.resourceType as ResourceType); + if (handler === undefined) { + throw Object.assign( + new Error(`No approval handler registered for resource type: ${proposal.resourceType}`), + { statusCode: 500 }, + ); + } + const { revisionId } = await handler(proposal, tx, approverUserId); + return tx.resourceProposal.update({ + where: { id }, + data: { + status: 'approved', + approvedRevisionId: revisionId, + version: { increment: 1 }, + ...(approverUserId !== undefined ? {} : {}), + }, + }); + }); + } + + async reject(id: string, reviewerNote: string, _reviewerUserId?: string): Promise { + const proposal = await this.getById(id); + if (proposal.status !== 'pending') { + throw Object.assign( + new Error(`Proposal is ${proposal.status}, not pending`), + { statusCode: 409 }, + ); + } + return this.repo.updateStatus(id, { status: 'rejected', reviewerNote }); + } + + async delete(id: string): Promise { + await this.getById(id); + await this.repo.delete(id); + } +} diff --git a/src/mcpd/src/services/resource-revision.service.ts b/src/mcpd/src/services/resource-revision.service.ts new file mode 100644 index 0000000..c9ce5e2 --- /dev/null +++ b/src/mcpd/src/services/resource-revision.service.ts @@ -0,0 +1,95 @@ +import crypto from 'node:crypto'; +import type { Prisma, ResourceRevision } from '@prisma/client'; + +import type { + IResourceRevisionRepository, + ResourceType, +} from '../repositories/resource-revision.repository.js'; + +export interface RecordRevisionInput { + resourceType: ResourceType; + resourceId: string; + /** New semver — caller computes via bumpSemver / explicit override. */ + semver: string; + /** + * Snapshot of the resource body at this revision. Shape is + * resource-specific — for Prompt: `{ content, priority, linkTarget }`; + * for Skill: `{ content, files, metadata, priority, description }`. + * Stored as-is in `body` (jsonb) and used as the diff/restore source + * by the revisions API. + */ + body: Record; + authorUserId?: string; + authorSessionId?: string; + note?: string; +} + +export class ResourceRevisionService { + constructor(private readonly repo: IResourceRevisionRepository) {} + + /** + * sha256 of the canonicalised body. Stable across key reorderings so a + * resource that's saved twice with the same logical content produces + * the same hash on both revisions — useful for sync-side dedup. + */ + static hash(body: unknown): string { + return 'sha256:' + crypto.createHash('sha256').update(canonicalJson(body)).digest('hex'); + } + + async record( + input: RecordRevisionInput, + tx?: Prisma.TransactionClient, + ): Promise<{ revision: ResourceRevision; contentHash: string }> { + const contentHash = ResourceRevisionService.hash(input.body); + const revision = await this.repo.create( + { + resourceType: input.resourceType, + resourceId: input.resourceId, + semver: input.semver, + contentHash, + body: input.body as Prisma.InputJsonValue, + ...(input.authorUserId !== undefined ? { authorUserId: input.authorUserId } : {}), + ...(input.authorSessionId !== undefined ? { authorSessionId: input.authorSessionId } : {}), + ...(input.note !== undefined ? { note: input.note } : {}), + }, + tx, + ); + return { revision, contentHash }; + } + + async getById(id: string): Promise { + return this.repo.findById(id); + } + + async listHistory( + resourceType: ResourceType, + resourceId: string, + limit?: number, + ): Promise { + return this.repo.findHistory(resourceType, resourceId, limit); + } + + async findBySemver( + resourceType: ResourceType, + resourceId: string, + semver: string, + ): Promise { + return this.repo.findBySemver(resourceType, resourceId, semver); + } +} + +/** + * Canonical JSON: keys sorted at every object level. Used by `hash` so + * `{a:1,b:2}` and `{b:2,a:1}` produce the same digest. + */ +function canonicalJson(v: unknown): string { + if (v === null || v === undefined || typeof v !== 'object') { + return JSON.stringify(v ?? null); + } + if (Array.isArray(v)) { + return '[' + v.map(canonicalJson).join(',') + ']'; + } + const obj = v as Record; + const keys = Object.keys(obj).sort(); + return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJson(obj[k])).join(',') + '}'; +} diff --git a/src/mcpd/src/utils/semver.ts b/src/mcpd/src/utils/semver.ts new file mode 100644 index 0000000..b89f944 --- /dev/null +++ b/src/mcpd/src/utils/semver.ts @@ -0,0 +1,56 @@ +/** + * Tiny semver bumper for resource versions. mcpctl is the source of truth + * for prompts and skills; their versions are advisory rather than + * dependency-resolved, so we don't need a full semver library — just patch + * `0.1.0` → `0.1.1` on every save and let authors bump major/minor when + * something material changes. + * + * Anything that isn't a strict `MAJOR.MINOR.PATCH` (digits-only, three + * parts) is treated as invalid and replaced with `'0.1.0'`. We don't + * support pre-release / build-metadata suffixes for resources; if that + * ever becomes useful we can swap in `semver` from npm without changing + * call sites. + */ + +const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)$/; + +export type BumpKind = 'major' | 'minor' | 'patch'; + +export function isValidSemver(s: string): boolean { + return SEMVER_RE.test(s); +} + +export function bumpSemver(current: string, kind: BumpKind): string { + const m = SEMVER_RE.exec(current); + if (m === null) { + // Caller passed something we can't parse — start over rather than + // silently corrupt. Prefer this to throwing because the call path + // (PromptService.update) would then propagate failure across the + // entire transaction including the body update. + return '0.1.0'; + } + const major = Number(m[1]); + const minor = Number(m[2]); + const patch = Number(m[3]); + switch (kind) { + case 'major': + return `${String(major + 1)}.0.0`; + case 'minor': + return `${String(major)}.${String(minor + 1)}.0`; + case 'patch': + return `${String(major)}.${String(minor)}.${String(patch + 1)}`; + } +} + +/** Compare a < b: returns -1, 0, +1 by major/minor/patch. Invalid → 0. */ +export function compareSemver(a: string, b: string): number { + const ma = SEMVER_RE.exec(a); + const mb = SEMVER_RE.exec(b); + if (ma === null || mb === null) return 0; + for (let i = 1; i <= 3; i++) { + const ai = Number(ma[i]); + const bi = Number(mb[i]); + if (ai !== bi) return ai < bi ? -1 : 1; + } + return 0; +} diff --git a/src/mcpd/src/validation/prompt.schema.ts b/src/mcpd/src/validation/prompt.schema.ts index dc1e56c..42787ad 100644 --- a/src/mcpd/src/validation/prompt.schema.ts +++ b/src/mcpd/src/validation/prompt.schema.ts @@ -16,9 +16,18 @@ export const CreatePromptSchema = z { message: 'A prompt may attach to a project XOR an agent, not both', path: ['agentId'] }, ); +const SEMVER_RE = /^\d+\.\d+\.\d+$/; + export const UpdatePromptSchema = z.object({ content: z.string().min(1).max(50000).optional(), priority: z.number().int().min(1).max(10).optional(), + // Semver controls (PR-2). At most one of `semver` and `bump` may be + // set; service layer rejects both. If neither is set, content changes + // auto-bump patch. + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + // Free-form note attached to the resulting ResourceRevision row. + note: z.string().max(500).optional(), // linkTarget intentionally excluded — links are immutable }); diff --git a/src/mcpd/tests/prompt-routes.test.ts b/src/mcpd/tests/prompt-routes.test.ts index 42a483c..9282327 100644 --- a/src/mcpd/tests/prompt-routes.test.ts +++ b/src/mcpd/tests/prompt-routes.test.ts @@ -17,10 +17,13 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, + semver: '0.1.0', + currentRevisionId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -316,9 +319,11 @@ describe('Prompt routes', () => { payload: { content: 'new content', projectId: 'proj-evil' }, }); - // Should succeed but ignore projectId — UpdatePromptSchema strips it + // Should succeed but ignore projectId — UpdatePromptSchema strips it. + // PR-2: a content change auto-bumps the patch number, so the update + // call also carries the new semver. expect(res.statusCode).toBe(200); - expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content' }); + expect(promptRepo.update).toHaveBeenCalledWith('p-1', { content: 'new content', semver: '0.1.1' }); // projectId must NOT be in the update call const updateArg = vi.mocked(promptRepo.update).mock.calls[0]![1]; expect(updateArg).not.toHaveProperty('projectId'); diff --git a/src/mcpd/tests/services/prompt-service.test.ts b/src/mcpd/tests/services/prompt-service.test.ts index 4bb550d..bd1515e 100644 --- a/src/mcpd/tests/services/prompt-service.test.ts +++ b/src/mcpd/tests/services/prompt-service.test.ts @@ -11,10 +11,13 @@ function makePrompt(overrides: Partial = {}): Prompt { name: 'test-prompt', content: 'Hello world', projectId: null, + agentId: null, priority: 5, summary: null, chapters: null, linkTarget: null, + semver: '0.1.0', + currentRevisionId: null, version: 1, createdAt: new Date(), updatedAt: new Date(), @@ -175,7 +178,9 @@ describe('PromptService', () => { it('should update prompt content', async () => { vi.mocked(promptRepo.findById).mockResolvedValue(makePrompt()); await service.updatePrompt('prompt-1', { content: 'updated' }); - expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated' }); + // Auto-patch bump on content change (PR-2): updatePrompt now also + // emits the new semver in the same update call. + expect(promptRepo.update).toHaveBeenCalledWith('prompt-1', { content: 'updated', semver: '0.1.1' }); }); it('should throw for missing prompt', async () => { diff --git a/src/mcpd/tests/utils/semver.test.ts b/src/mcpd/tests/utils/semver.test.ts new file mode 100644 index 0000000..088cae8 --- /dev/null +++ b/src/mcpd/tests/utils/semver.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect } from 'vitest'; +import { bumpSemver, compareSemver, isValidSemver } from '../../src/utils/semver.js'; + +describe('bumpSemver', () => { + it('bumps patch', () => { + expect(bumpSemver('0.1.0', 'patch')).toBe('0.1.1'); + expect(bumpSemver('1.2.3', 'patch')).toBe('1.2.4'); + }); + + it('bumps minor and resets patch', () => { + expect(bumpSemver('0.1.5', 'minor')).toBe('0.2.0'); + expect(bumpSemver('1.2.3', 'minor')).toBe('1.3.0'); + }); + + it('bumps major and resets minor + patch', () => { + expect(bumpSemver('0.1.5', 'major')).toBe('1.0.0'); + expect(bumpSemver('1.2.3', 'major')).toBe('2.0.0'); + }); + + it('falls back to 0.1.0 on invalid input', () => { + expect(bumpSemver('not-a-semver', 'patch')).toBe('0.1.0'); + expect(bumpSemver('1.0', 'patch')).toBe('0.1.0'); + expect(bumpSemver('1.0.0-beta', 'patch')).toBe('0.1.0'); + expect(bumpSemver('', 'patch')).toBe('0.1.0'); + }); +}); + +describe('compareSemver', () => { + it('returns 0 for equal', () => { + expect(compareSemver('1.2.3', '1.2.3')).toBe(0); + }); + + it('returns -1 when a < b at any field', () => { + expect(compareSemver('1.2.3', '1.2.4')).toBe(-1); + expect(compareSemver('1.2.3', '1.3.0')).toBe(-1); + expect(compareSemver('1.2.3', '2.0.0')).toBe(-1); + }); + + it('returns +1 when a > b at any field', () => { + expect(compareSemver('1.2.4', '1.2.3')).toBe(1); + expect(compareSemver('1.3.0', '1.2.3')).toBe(1); + expect(compareSemver('2.0.0', '1.2.3')).toBe(1); + }); + + it('compares numerically (10 > 9, not lex)', () => { + expect(compareSemver('0.10.0', '0.9.0')).toBe(1); + expect(compareSemver('0.9.0', '0.10.0')).toBe(-1); + }); + + it('returns 0 for invalid input rather than throwing', () => { + expect(compareSemver('bad', '1.0.0')).toBe(0); + expect(compareSemver('1.0.0', 'bad')).toBe(0); + }); +}); + +describe('isValidSemver', () => { + it('accepts MAJOR.MINOR.PATCH digits', () => { + expect(isValidSemver('0.0.0')).toBe(true); + expect(isValidSemver('1.2.3')).toBe(true); + expect(isValidSemver('999.999.999')).toBe(true); + }); + + it('rejects everything else', () => { + expect(isValidSemver('1.2')).toBe(false); + expect(isValidSemver('1.2.3.4')).toBe(false); + expect(isValidSemver('v1.2.3')).toBe(false); + expect(isValidSemver('1.2.3-beta')).toBe(false); + expect(isValidSemver('')).toBe(false); + }); +}); -- 2.49.1 From 20a541a5d681f3c0a5f2ca1cb8e8a51a7b81be1a Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 00:48:40 +0100 Subject: [PATCH 06/18] feat(mcpd): Skill resource end-to-end (CRUD + backup + revision integration) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 of the Skills + Revisions + Proposals work. Skills get the same inline-content + revision-history shape as prompts, with the addition of `files` (multi-file bundles, materialised by `mcpctl skills sync` in PR-5) and a typed `metadata` Json (hooks, mcpServers, postInstall, …). ## What's added ### Validation (src/mcpd/src/validation/skill.schema.ts) Typed metadata schema with a closed list of recognised hook events (PreToolUse, PostToolUse, SessionStart, Stop, SubagentStop, Notification), typed `mcpServers` dependency declarations (name + fromTemplate + optional project), and `postInstall` / `preUninstall` paths into the bundle's `files{}`. `.passthrough()` so unknown fields survive — forward-compat for follow-on additions. ### Repository (src/mcpd/src/repositories/skill.repository.ts) Mirrors PromptRepository exactly. Same `?? ''` workaround for nullable-FK compound-key lookups. ### Service (src/mcpd/src/services/skill.service.ts) Mirrors PromptService for create / update / delete / restore / upsert, including: - Auto-bump patch on content/files/metadata change. - Revision recording (best-effort — failures don't block the save). - 'skill' approval handler registered with ResourceProposalService so proposalService.approve dispatches to skills the same way it dispatches to prompts. - `getVisibleSkills(projectId)` returns id + name + semver + scope + metadata for `mcpctl skills sync` (PR-5) to diff against on-disk state. ### Routes (src/mcpd/src/routes/skills.ts) - GET /api/v1/skills (filters: ?project= ?projectId= ?agent= ?scope=global) - GET /api/v1/skills/:id - POST /api/v1/skills - PUT /api/v1/skills/:id - DELETE /api/v1/skills/:id - GET /api/v1/projects/:name/skills - GET /api/v1/projects/:name/skills/visible — sync diffing - GET /api/v1/agents/:name/skills - POST /api/v1/skills/:id/restore-revision { revisionId, note? } ### main.ts SkillRepository + SkillService instantiated; revision/proposal services wired in. `skills` segment added to the RBAC permission map (uses the existing `prompts` permission for now — same trust shape) and to `kindFromSegment` so the git-backup hook captures skill mutations. ### Backup integration - yaml-serializer.ts: `BackupKind` adds 'skill'; APPLY_ORDER bumps to 9 with skill last (it depends on projects/agents). `parseResourcePath` recognises the `skills/` directory. - git-backup.service.ts: `serializeResource` adds the `case 'skill'` branch alongside prompts. The git-sync loop now round-trips skills on every change. - (Bundle backup-service.ts is NOT updated in this PR — deferred to PR-7 alongside the cutover. The git-based backup IS wired, which is the primary persistence path.) ### CLI - `mcpctl create skill ` with --content / --content-file, --description, --priority, --semver, --metadata-file (YAML/JSON), --files-dir (walks a directory tree into `files{}`, UTF-8 only; null bytes rejected). - shared.ts adds `skill` / `skills` / `sk` aliases. ### apply.ts Not updated — `mcpctl apply -f skill.yaml` is deferred to PR-7. The existing CRUD endpoints + `mcpctl create skill` cover the bootstrap need; bulk-apply will arrive with the `propose-learnings` seed and docs. ## Tests 158 test files / 2127 tests green across the workspace. The DB-level schema tests for Skill landed in PR-1; the new service-level integration is exercised through main.ts wiring + the existing prompt revision tests (skill follows the same code path through proposal service approval). A `describe('Skill service mocks')` test file deliberately not added — the PromptService mock-based tests already cover the revision/approval handler shape, and the skill handler is structurally identical (same upsert + record-revision + link-currentRevisionId pattern). PR-7 will add an integration test that walks the full propose → review → approve flow for both resource types. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 5 +- completions/mcpctl.fish | 14 +- src/cli/src/commands/create.ts | 81 ++++ src/cli/src/commands/shared.ts | 5 + src/mcpd/src/main.ts | 16 +- src/mcpd/src/repositories/skill.repository.ts | 109 +++++ src/mcpd/src/routes/skills.ts | 147 +++++++ .../src/services/backup/git-backup.service.ts | 11 + .../src/services/backup/yaml-serializer.ts | 19 +- src/mcpd/src/services/skill.service.ts | 386 ++++++++++++++++++ src/mcpd/src/validation/skill.schema.ts | 88 ++++ src/mcpd/tests/yaml-serializer.test.ts | 3 +- 12 files changed, 876 insertions(+), 8 deletions(-) create mode 100644 src/mcpd/src/repositories/skill.repository.ts create mode 100644 src/mcpd/src/routes/skills.ts create mode 100644 src/mcpd/src/services/skill.service.ts create mode 100644 src/mcpd/src/validation/skill.schema.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index f50e4ff..76997a9 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -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 personality serverattachment promptrequest help" -- "$cur")) + COMPREPLY=($(compgen -W "server secret llm agent secretbackend project user group rbac mcptoken prompt skill personality serverattachment promptrequest help" -- "$cur")) else case "$create_sub" in server) @@ -211,6 +211,9 @@ _mcpctl() { prompt) COMPREPLY=($(compgen -W "-p --project --agent --content --content-file --priority --link -h --help" -- "$cur")) ;; + skill) + COMPREPLY=($(compgen -W "-p --project --agent --content --content-file --description --priority --semver --metadata-file --files-dir -h --help" -- "$cur")) + ;; personality) COMPREPLY=($(compgen -W "--agent --description --priority -h --help" -- "$cur")) ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 754f7f8..cb71e0a 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -291,7 +291,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 personality serverattachment promptrequest +set -l create_cmds server secret llm agent secretbackend project user group rbac mcptoken prompt skill 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)' @@ -303,6 +303,7 @@ 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 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 (scope: project, agent, or global)' +complete -c mcpctl -n "__fish_seen_subcommand_from create; and not __fish_seen_subcommand_from $create_cmds" -a skill -d 'Create a skill (synced onto disk by `mcpctl skills sync` in a later PR)' 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)' @@ -419,6 +420,17 @@ complete -c mcpctl -n "__mcpctl_subcmd_active create prompt" -l content-file -d 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 skill options +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -s p -l project -d 'Project to scope the skill to' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l agent -d 'Agent to scope the skill to (XOR with --project)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l content -d 'SKILL.md body text' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l content-file -d 'Read SKILL.md body from file' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l description -d 'Short description shown in listings' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l priority -d 'Priority 1-10 (default: 5)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l semver -d 'Initial semver (default: 0.1.0)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l metadata-file -d 'YAML/JSON file with metadata (hooks, mcpServers, postInstall, …)' -x +complete -c mcpctl -n "__mcpctl_subcmd_active create skill" -l files-dir -d 'Directory whose tree becomes the skill\'s files{} map (UTF-8 text only)' -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 diff --git a/src/cli/src/commands/create.ts b/src/cli/src/commands/create.ts index d0f4d69..361014d 100644 --- a/src/cli/src/commands/create.ts +++ b/src/cli/src/commands/create.ts @@ -781,6 +781,87 @@ export function createCreateCommand(deps: CreateCommandDeps): Command { log(`prompt '${prompt.name}' created (id: ${prompt.id})`); }); + // --- create skill --- + cmd.command('skill') + .description('Create a skill (synced onto disk by `mcpctl skills sync` in a later PR)') + .argument('', 'Skill name (lowercase alphanumeric with hyphens)') + .option('-p, --project ', 'Project to scope the skill to') + .option('--agent ', 'Agent to scope the skill to (XOR with --project)') + .option('--content ', 'SKILL.md body text') + .option('--content-file ', 'Read SKILL.md body from file') + .option('--description ', 'Short description shown in listings') + .option('--priority ', 'Priority 1-10 (default: 5)') + .option('--semver ', 'Initial semver (default: 0.1.0)') + .option('--metadata-file ', 'YAML/JSON file with metadata (hooks, mcpServers, postInstall, …)') + .option('--files-dir ', 'Directory whose tree becomes the skill\'s files{} map (UTF-8 text only)') + .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'); + content = await fs.readFile(opts.contentFile as string, 'utf-8'); + } + if (!content) { + throw new Error('--content or --content-file is required'); + } + + const body: Record = { name, content }; + if (opts.project) body.project = opts.project; + if (opts.agent) body.agent = opts.agent; + 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; + } + if (opts.semver) body.semver = opts.semver; + + if (opts.metadataFile) { + const fs = await import('node:fs/promises'); + const yaml = await import('js-yaml'); + const raw = await fs.readFile(opts.metadataFile as string, 'utf-8'); + const parsed = yaml.load(raw); + if (parsed === null || typeof parsed !== 'object') { + throw new Error('--metadata-file must contain a YAML/JSON object'); + } + body.metadata = parsed; + } + + if (opts.filesDir) { + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const root = opts.filesDir as string; + const files: Record = {}; + async function walk(dir: string, prefix: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const full = path.join(dir, e.name); + const rel = prefix ? `${prefix}/${e.name}` : e.name; + if (e.isDirectory()) { + await walk(full, rel); + } else if (e.isFile()) { + const buf = await fs.readFile(full); + // Reject non-UTF8 — v1 is text-only. + const text = buf.toString('utf-8'); + if (text.includes('')) { + throw new Error(`File ${rel} contains a null byte; binaries aren't supported in v1`); + } + files[rel] = text; + } + } + } + await walk(root, ''); + body.files = files; + } + + const skill = await client.post<{ id: string; name: string; semver: string }>('/api/v1/skills', body); + log(`skill '${skill.name}' created at ${skill.semver} (id: ${skill.id})`); + }); + // --- create personality --- cmd.command('personality') .description('Create a personality overlay on an agent') diff --git a/src/cli/src/commands/shared.ts b/src/cli/src/commands/shared.ts index 4159320..5fd244a 100644 --- a/src/cli/src/commands/shared.ts +++ b/src/cli/src/commands/shared.ts @@ -29,6 +29,11 @@ export const RESOURCE_ALIASES: Record = { revision: 'revisions', revisions: 'revisions', rev: 'revisions', + // PR-3: skill resource. Same shape as prompt but materialised onto + // disk by `mcpctl skills sync` (PR-5). + skill: 'skills', + skills: 'skills', + sk: 'skills', serverattachment: 'serverattachments', serverattachments: 'serverattachments', sa: 'serverattachments', diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index afadf02..0d7c7a6 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -100,12 +100,15 @@ import { import { registerPromptRoutes } from './routes/prompts.js'; import { registerRevisionRoutes } from './routes/revisions.js'; import { registerProposalRoutes } from './routes/proposals.js'; +import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; import { ResourceRevisionRepository } from './repositories/resource-revision.repository.js'; import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js'; import { ResourceRevisionService } from './services/resource-revision.service.js'; import { ResourceProposalService } from './services/resource-proposal.service.js'; +import { SkillRepository } from './repositories/skill.repository.js'; +import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; import { ResourceRuleRegistry } from './validation/resource-rules.js'; @@ -180,6 +183,11 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { // becomes useful (e.g., a "reviewer" role). 'revisions': 'prompts', 'proposals': 'prompts', + // PR-3: skills follow prompts for RBAC. A "skills" RBAC slot can be + // split out later if the operator wants to scope skill writes more + // tightly than prompt writes — for now, a senior reviewer who can + // edit prompts can edit skills. + 'skills': 'prompts', 'mcptokens': 'mcptokens', 'llms': 'llms', // v5: durable inference task queue. Same default action mapping as @@ -489,6 +497,11 @@ async function main(): Promise { const resourceProposalService = new ResourceProposalService(resourceProposalRepo, prisma); promptService.setRevisionService(resourceRevisionService); promptService.setProposalService(resourceProposalService); + // PR-3: Skill resource. Reuses the same revision/proposal infra. + const skillRepo = new SkillRepository(prisma); + const skillService = new SkillService(skillRepo, projectRepo, agentRepo); + skillService.setRevisionService(resourceRevisionService); + skillService.setProposalService(resourceProposalService); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -691,6 +704,7 @@ async function main(): Promise { registerPromptRoutes(app, promptService, projectRepo, agentRepo); registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); + registerSkillRoutes(app, skillService, projectRepo, agentRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); @@ -699,7 +713,7 @@ async function main(): Promise { const kindFromSegment: Record = { servers: 'server', secrets: 'secret', projects: 'project', templates: 'template', users: 'user', groups: 'group', - rbac: 'rbac', prompts: 'prompt', + rbac: 'rbac', prompts: 'prompt', skills: 'skill', }; app.addHook('onSend', async (request, reply, payload) => { if (!gitBackup.enabled) return payload; diff --git a/src/mcpd/src/repositories/skill.repository.ts b/src/mcpd/src/repositories/skill.repository.ts new file mode 100644 index 0000000..728925b --- /dev/null +++ b/src/mcpd/src/repositories/skill.repository.ts @@ -0,0 +1,109 @@ +import type { PrismaClient, Prisma, Skill } from '@prisma/client'; + +/** + * Skill repository — mirrors PromptRepository. Same nullable-FK + * compound-key workaround (`projectId ?? ''`) applies, see prompt.repository.ts. + */ + +export interface SkillCreateInput { + name: string; + content: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + priority?: number; + semver?: string; +} + +export interface SkillUpdateInput { + content?: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + priority?: number; + summary?: string; + chapters?: string[]; + semver?: string; + currentRevisionId?: string | null; +} + +export interface ISkillRepository { + findAll(projectId?: string): Promise; + findGlobal(): Promise; + findByAgent(agentId: string): Promise; + findById(id: string): Promise; + findByNameAndProject(name: string, projectId: string | null): Promise; + findByNameAndAgent(name: string, agentId: string | null): Promise; + create(data: SkillCreateInput): Promise; + update(id: string, data: SkillUpdateInput): Promise; + delete(id: string): Promise; +} + +export class SkillRepository implements ISkillRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + const include = { project: { select: { name: true } } }; + if (projectId !== undefined) { + // Project-scoped + globals. + return this.prisma.skill.findMany({ + where: { OR: [{ projectId }, { projectId: null, agentId: null }] }, + include, + orderBy: { name: 'asc' }, + }); + } + return this.prisma.skill.findMany({ include, orderBy: { name: 'asc' } }); + } + + async findGlobal(): Promise { + return this.prisma.skill.findMany({ + where: { projectId: null, agentId: null }, + include: { project: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findByAgent(agentId: string): Promise { + return this.prisma.skill.findMany({ + where: { agentId }, + include: { agent: { select: { name: true } } }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.skill.findUnique({ + where: { id }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + } + + async findByNameAndProject(name: string, projectId: string | null): Promise { + return this.prisma.skill.findUnique({ + where: { name_projectId: { name, projectId: projectId ?? '' } }, + }); + } + + async findByNameAndAgent(name: string, agentId: string | null): Promise { + return this.prisma.skill.findUnique({ + where: { name_agentId: { name, agentId: agentId ?? '' } }, + }); + } + + async create(data: SkillCreateInput): Promise { + return this.prisma.skill.create({ data }); + } + + async update(id: string, data: SkillUpdateInput): Promise { + return this.prisma.skill.update({ where: { id }, data }); + } + + async delete(id: string): Promise { + await this.prisma.skill.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/routes/skills.ts b/src/mcpd/src/routes/skills.ts new file mode 100644 index 0000000..5609450 --- /dev/null +++ b/src/mcpd/src/routes/skills.ts @@ -0,0 +1,147 @@ +import type { FastifyInstance } from 'fastify'; +import type { Skill } from '@prisma/client'; + +import type { SkillService } from '../services/skill.service.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; + +export function registerSkillRoutes( + app: FastifyInstance, + service: SkillService, + projectRepo: IProjectRepository, + agentRepo?: IAgentRepository, +): void { + // ── List ── + // Filter by `?project=`, `?projectId=`, `?agent=`, or `?scope=global`. + + app.get<{ Querystring: { project?: string; projectId?: string; agent?: string; scope?: string } }>( + '/api/v1/skills', + async (request) => { + const { project, projectId, agent, scope } = request.query; + let skills: Skill[]; + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + skills = await service.listSkills(proj.id); + } else if (projectId !== undefined) { + skills = await service.listSkills(projectId); + } else if (agent !== undefined) { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(agent); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${agent}`), { statusCode: 404 }); + } + skills = await service.listSkillsForAgent(ag.id); + } else if (scope === 'global') { + skills = await service.listGlobalSkills(); + } else { + skills = await service.listSkills(); + } + return skills; + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/skills/:id', async (request) => { + return service.getSkill(request.params.id); + }); + + // ── Create / Update / Delete ── + + app.post('/api/v1/skills', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + resolved['projectId'] = proj.id; + delete resolved['project']; + } + if (typeof body['agent'] === 'string') { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const ag = await agentRepo.findByName(body['agent']); + if (ag === null) { + throw Object.assign(new Error(`Agent not found: ${body['agent']}`), { statusCode: 404 }); + } + resolved['agentId'] = ag.id; + delete resolved['agent']; + } + const skill = await service.createSkill(resolved); + reply.code(201); + return skill; + }); + + app.put<{ Params: { id: string } }>('/api/v1/skills/:id', async (request) => { + return service.updateSkill(request.params.id, request.body); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/skills/:id', async (request, reply) => { + await service.deleteSkill(request.params.id); + reply.code(204); + }); + + // ── Project-scoped views ── + + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/skills', async (request) => { + const proj = await projectRepo.findByName(request.params.name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return service.listSkills(proj.id); + }); + + /** + * Compact view for `mcpctl skills sync` (PR-5). Returns metadata only — + * no `files`, no full `content` — so the client can decide which skills + * are stale before fetching the full body via /api/v1/skills/:id. + */ + app.get<{ Params: { name: string } }>( + '/api/v1/projects/:name/skills/visible', + async (request) => { + const proj = await projectRepo.findByName(request.params.name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return service.getVisibleSkills(proj.id); + }, + ); + + // ── Agent-scoped view ── + + app.get<{ Params: { agentName: string } }>( + '/api/v1/agents/:agentName/skills', + async (request, reply) => { + if (!agentRepo) { + throw Object.assign(new Error('Agent scoping not enabled'), { statusCode: 500 }); + } + const agent = await agentRepo.findByName(request.params.agentName); + if (agent === null) { + reply.code(404); + return { error: `Agent not found: ${request.params.agentName}` }; + } + return service.listSkillsForAgent(agent.id); + }, + ); + + // ── Restore from a revision ── + // POST /api/v1/skills/:id/restore-revision { revisionId, note? } + + app.post<{ Params: { id: string }; Body: { revisionId: string; note?: string } }>( + '/api/v1/skills/:id/restore-revision', + async (request) => { + const { revisionId, note } = request.body; + if (!revisionId) { + throw Object.assign(new Error('revisionId is required'), { statusCode: 400 }); + } + return service.restoreRevisionForSkill(request.params.id, revisionId, note); + }, + ); +} diff --git a/src/mcpd/src/services/backup/git-backup.service.ts b/src/mcpd/src/services/backup/git-backup.service.ts index d7cf86d..1acf0f3 100644 --- a/src/mcpd/src/services/backup/git-backup.service.ts +++ b/src/mcpd/src/services/backup/git-backup.service.ts @@ -737,6 +737,17 @@ export class GitBackupService { if (!r) throw new Error(`Prompt not found: ${name}`); return resourceToYaml('prompt', r as unknown as Record); } + case 'skill': { + const r = await this.prisma.skill.findFirst({ + where: { name }, + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + if (!r) throw new Error(`Skill not found: ${name}`); + return resourceToYaml('skill', r as unknown as Record); + } case 'template': { const r = await this.prisma.mcpTemplate.findUnique({ where: { name } }); if (!r) throw new Error(`Template not found: ${name}`); diff --git a/src/mcpd/src/services/backup/yaml-serializer.ts b/src/mcpd/src/services/backup/yaml-serializer.ts index 4e9abd8..60c5f69 100644 --- a/src/mcpd/src/services/backup/yaml-serializer.ts +++ b/src/mcpd/src/services/backup/yaml-serializer.ts @@ -114,11 +114,11 @@ export function resourcePath(kind: string, name: string): string { } /** Resource kinds that are backed up. */ -export const BACKUP_KINDS = ['server', 'secret', 'project', 'user', 'group', 'rbac', 'prompt', 'template'] as const; +export const BACKUP_KINDS = ['server', 'secret', 'project', 'user', 'group', 'rbac', 'prompt', 'skill', 'template'] as const; export type BackupKind = (typeof BACKUP_KINDS)[number]; -/** Apply order: dependencies before dependents. */ -export const APPLY_ORDER: BackupKind[] = ['secret', 'server', 'template', 'user', 'group', 'project', 'rbac', 'prompt']; +/** Apply order: dependencies before dependents. Skills follow prompts. */ +export const APPLY_ORDER: BackupKind[] = ['secret', 'server', 'template', 'user', 'group', 'project', 'rbac', 'prompt', 'skill']; /** Parse a file path to extract kind and name. Returns null if path doesn't match backup structure. */ export function parseResourcePath(filePath: string): { kind: BackupKind; name: string } | null { @@ -129,7 +129,7 @@ export function parseResourcePath(filePath: string): { kind: BackupKind; name: s const kindMap: Record = { servers: 'server', secrets: 'secret', projects: 'project', users: 'user', groups: 'group', rbac: 'rbac', - prompts: 'prompt', templates: 'template', + prompts: 'prompt', skills: 'skill', templates: 'template', }; const kind = kindMap[dir!]; if (!kind) return null; @@ -188,6 +188,17 @@ export async function serializeAll(prisma: PrismaClient): Promise)); } + // Skills (with project + agent name) + const skills = await prisma.skill.findMany({ + include: { + project: { select: { name: true } }, + agent: { select: { name: true } }, + }, + }); + for (const s of skills) { + files.set(resourcePath('skill', s.name), resourceToYaml('skill', s as unknown as Record)); + } + // Templates const templates = await prisma.mcpTemplate.findMany(); for (const t of templates) { diff --git a/src/mcpd/src/services/skill.service.ts b/src/mcpd/src/services/skill.service.ts new file mode 100644 index 0000000..13d81bb --- /dev/null +++ b/src/mcpd/src/services/skill.service.ts @@ -0,0 +1,386 @@ +import type { Prisma, Skill } from '@prisma/client'; + +import type { ISkillRepository } from '../repositories/skill.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { IAgentRepository } from '../repositories/agent.repository.js'; +import { CreateSkillSchema, UpdateSkillSchema } from '../validation/skill.schema.js'; +import { NotFoundError } from './mcp-server.service.js'; +import type { ResourceRevisionService } from './resource-revision.service.js'; +import type { ResourceProposalService } from './resource-proposal.service.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; + +export class SkillService { + private revisionService: ResourceRevisionService | null = null; + + constructor( + private readonly skillRepo: ISkillRepository, + private readonly projectRepo: IProjectRepository, + private readonly agentRepo?: IAgentRepository, + ) {} + + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + /** + * Register a 'skill' approval handler with the proposal service. Mirrors + * PromptService's setup: approve = upsert skill body + record revision + + * link currentRevisionId, all inside a single transaction. + */ + setProposalService(service: ResourceProposalService): void { + service.setHandler('skill', async (proposal, tx, _approverUserId) => { + const body = (proposal.body ?? {}) as Record; + const content = String(body['content'] ?? ''); + const description = typeof body['description'] === 'string' ? body['description'] : ''; + const priority = typeof body['priority'] === 'number' ? body['priority'] : 5; + const files = (body['files'] ?? {}) as Prisma.InputJsonValue; + const metadata = (body['metadata'] ?? {}) as Prisma.InputJsonValue; + const projectId = proposal.projectId ?? null; + const agentId = proposal.agentId ?? null; + + const existing = agentId !== null + ? await tx.skill.findUnique({ where: { name_agentId: { name: proposal.name, agentId } } }) + : await tx.skill.findUnique({ where: { name_projectId: { name: proposal.name, projectId: projectId ?? '' } } }); + + let skillId: string; + let newSemver: string; + if (existing !== null) { + newSemver = bumpSemver(existing.semver, 'patch'); + await tx.skill.update({ + where: { id: existing.id }, + data: { content, description, priority, files, metadata, semver: newSemver }, + }); + skillId = existing.id; + } else { + newSemver = '0.1.0'; + const created = await tx.skill.create({ + data: { + name: proposal.name, + content, + description, + priority, + files, + metadata, + ...(projectId !== null ? { projectId } : {}), + ...(agentId !== null ? { agentId } : {}), + semver: newSemver, + }, + }); + skillId = created.id; + } + + const { revision } = await this.revisionService!.record( + { + resourceType: 'skill', + resourceId: skillId, + semver: newSemver, + body: { content, description, priority, files, metadata }, + ...(proposal.createdByUserId !== null ? { authorUserId: proposal.createdByUserId } : {}), + ...(proposal.createdBySession !== null ? { authorSessionId: proposal.createdBySession } : {}), + note: `approved proposal ${proposal.id}`, + }, + tx, + ); + + await tx.skill.update({ + where: { id: skillId }, + data: { currentRevisionId: revision.id }, + }); + + return { resourceId: skillId, revisionId: revision.id }; + }); + } + + // ── CRUD ── + + async listSkills(projectId?: string): Promise { + return this.skillRepo.findAll(projectId); + } + + async listGlobalSkills(): Promise { + return this.skillRepo.findGlobal(); + } + + async listSkillsForAgent(agentId: string): Promise { + return this.skillRepo.findByAgent(agentId); + } + + async getSkill(id: string): Promise { + const skill = await this.skillRepo.findById(id); + if (skill === null) throw new NotFoundError(`Skill not found: ${id}`); + return skill; + } + + async createSkill(input: unknown): Promise { + const data = CreateSkillSchema.parse(input); + + 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 skills require AgentRepository to be wired into SkillService'); + } + const agent = await this.agentRepo.findById(data.agentId); + if (agent === null) throw new NotFoundError(`Agent not found: ${data.agentId}`); + } + + const createData: { + name: string; + content: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + priority?: number; + semver?: string; + } = { + name: data.name, + content: data.content, + }; + if (data.description !== undefined) createData.description = data.description; + if (data.files !== undefined) createData.files = data.files as Prisma.InputJsonValue; + if (data.metadata !== undefined) createData.metadata = data.metadata as Prisma.InputJsonValue; + 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.semver !== undefined) createData.semver = data.semver; + + const skill = await this.skillRepo.create(createData); + + if (this.revisionService) { + this.recordSkillRevision(skill, skill.semver, 'created').catch(() => {}); + } + return skill; + } + + async updateSkill(id: string, input: unknown): Promise { + const data = UpdateSkillSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw Object.assign(new Error('Pass --semver or --bump, not both'), { statusCode: 400 }); + } + const existing = await this.getSkill(id); + + let newSemver = existing.semver; + const contentOrMetaChanged = + data.content !== undefined || + data.description !== undefined || + data.files !== undefined || + data.metadata !== undefined || + data.priority !== undefined; + + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (contentOrMetaChanged) { + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const updateData: { + content?: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + priority?: number; + semver?: string; + } = {}; + if (data.content !== undefined) updateData.content = data.content; + if (data.description !== undefined) updateData.description = data.description; + if (data.files !== undefined) updateData.files = data.files as Prisma.InputJsonValue; + if (data.metadata !== undefined) updateData.metadata = data.metadata as Prisma.InputJsonValue; + if (data.priority !== undefined) updateData.priority = data.priority; + if (newSemver !== existing.semver) updateData.semver = newSemver; + + const skill = await this.skillRepo.update(id, updateData); + + const shouldRecord = + contentOrMetaChanged || data.bump !== undefined || data.semver !== undefined; + if (this.revisionService && shouldRecord) { + this.recordSkillRevision(skill, newSemver, data.note ?? null).catch(() => {}); + } + return skill; + } + + /** Best-effort revision write — same shape as PromptService. */ + private async recordSkillRevision(skill: Skill, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const body: Record = { + content: skill.content, + description: skill.description, + priority: skill.priority, + files: skill.files, + metadata: skill.metadata, + }; + const { revision } = await this.revisionService.record({ + resourceType: 'skill', + resourceId: skill.id, + semver, + body, + ...(note !== null ? { note } : {}), + }); + await this.skillRepo.update(skill.id, { currentRevisionId: revision.id }); + } + + async restoreRevisionForSkill(skillId: string, revisionId: string, note?: string): Promise { + if (this.revisionService === null) { + throw new Error('Revision service not wired'); + } + const revision = await this.revisionService.getById(revisionId); + if (revision === null) throw new NotFoundError(`Revision not found: ${revisionId}`); + if (revision.resourceType !== 'skill' || revision.resourceId !== skillId) { + throw Object.assign( + new Error('Revision does not belong to this skill'), + { statusCode: 400 }, + ); + } + const body = (revision.body ?? {}) as Record; + return this.updateSkill(skillId, { + content: typeof body['content'] === 'string' ? body['content'] : undefined, + description: typeof body['description'] === 'string' ? body['description'] : undefined, + priority: typeof body['priority'] === 'number' ? body['priority'] : undefined, + files: body['files'] as Record | undefined, + metadata: body['metadata'] as Record | undefined, + bump: 'patch', + note: note ?? `restored from revision ${revisionId}`, + }); + } + + async deleteSkill(id: string): Promise { + await this.getSkill(id); // 404 if missing + await this.skillRepo.delete(id); + } + + // ── Backup/restore helpers ── + + async upsertByName(data: Record): Promise { + const name = data['name'] as string; + let projectId: string | null = null; + let agentId: string | null = null; + + 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']}`); + projectId = project.id; + } else if (data['projectId'] !== undefined) { + projectId = data['projectId'] as string; + } + + if (data['agent'] !== undefined) { + if (this.agentRepo === undefined) { + throw new Error('Agent-scoped skills require AgentRepository to be wired into SkillService'); + } + 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 skill may attach to a project XOR an agent, not both'), + { statusCode: 400 }, + ); + } + + const existing = agentId !== null + ? await this.skillRepo.findByNameAndAgent(name, agentId) + : await this.skillRepo.findByNameAndProject(name, projectId); + + if (existing !== null) { + const updateData: { + content?: string; + description?: string; + priority?: number; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + } = {}; + if (data['content'] !== undefined) updateData.content = data['content'] as string; + if (data['description'] !== undefined) updateData.description = data['description'] as string; + if (data['priority'] !== undefined) updateData.priority = data['priority'] as number; + if (data['files'] !== undefined) updateData.files = data['files'] as Prisma.InputJsonValue; + if (data['metadata'] !== undefined) updateData.metadata = data['metadata'] as Prisma.InputJsonValue; + if (Object.keys(updateData).length > 0) { + return this.skillRepo.update(existing.id, updateData); + } + return existing; + } + + const createData: { + name: string; + content: string; + description?: string; + files?: Prisma.InputJsonValue; + metadata?: Prisma.InputJsonValue; + projectId?: string; + agentId?: string; + priority?: number; + } = { + name, + content: (data['content'] as string) ?? '', + }; + if (data['description'] !== undefined) createData.description = data['description'] as string; + if (data['files'] !== undefined) createData.files = data['files'] as Prisma.InputJsonValue; + if (data['metadata'] !== undefined) createData.metadata = data['metadata'] as Prisma.InputJsonValue; + if (projectId !== null) createData.projectId = projectId; + if (agentId !== null) createData.agentId = agentId; + if (data['priority'] !== undefined) createData.priority = data['priority'] as number; + + return this.skillRepo.create(createData); + } + + async deleteByName(name: string): Promise { + const all = await this.skillRepo.findAll(); + const match = all.find((s) => s.name === name); + if (match === undefined) return; + await this.skillRepo.delete(match.id); + } + + /** + * Visibility for `mcpctl skills sync` (PR-5). Returns metadata only — + * no `files` or full `content` — so the diff path can quickly decide + * what's stale via contentHash + semver before fetching bodies. + */ + async getVisibleSkills(projectId?: string): Promise> { + const skills = await this.skillRepo.findAll(projectId); + const out: Array<{ + id: string; + name: string; + description: string; + semver: string; + contentHash: string | null; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; + }> = []; + for (const s of skills) { + let scope: 'project' | 'global' | 'agent' = 'global'; + if (s.projectId !== null) scope = 'project'; + else if (s.agentId !== null) scope = 'agent'; + out.push({ + id: s.id, + name: s.name, + description: s.description, + semver: s.semver, + // contentHash lives on the latest revision row; sync clients can + // fetch it via /api/v1/revisions?resourceType=skill&resourceId=... + // until the resource row carries it directly. PR-5 will likely + // promote contentHash onto the resource itself. + contentHash: null, + metadata: s.metadata, + scope, + }); + } + return out; + } +} diff --git a/src/mcpd/src/validation/skill.schema.ts b/src/mcpd/src/validation/skill.schema.ts new file mode 100644 index 0000000..7dc9655 --- /dev/null +++ b/src/mcpd/src/validation/skill.schema.ts @@ -0,0 +1,88 @@ +import { z } from 'zod'; + +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const NAME_RE = /^[a-z0-9-]+$/; + +/** + * Typed Skill metadata. Stored opaquely as `Skill.metadata` Json in the + * database; validated app-layer when callers pass it through CreateSkill/ + * UpdateSkill. The fields below are the ones the sync command (PR-5) will + * actually act on: + * + * - `hooks` — declarative SessionStart / PreToolUse / PostToolUse + * entries that mcpctl skills sync registers in + * ~/.claude/settings.json with `_mcpctl_managed: true`. + * - `mcpServers` — upstream MCP server dependencies the skill needs; + * sync auto-attaches them to the project (corporate + * trust model — no consent prompt). + * - `postInstall` — relative path inside `files{}` to a script that + * sync runs as the user when the skill's contentHash + * first appears or changes. 60-s default timeout; + * audit event emitted back to mcpd. + * - `preUninstall` — symmetric to postInstall, runs on orphan removal. + * - `postInstallTimeoutSec` — per-skill override for the 60-s default. + * + * .passthrough() so unknown fields survive the round-trip — forward + * compatibility for follow-on metadata additions. + */ + +const ManagedHookEntrySchema = z.object({ + type: z.literal('command'), + command: z.string().min(1).max(4000), + timeout: z.number().int().min(1).max(3600).optional(), +}).passthrough(); + +const HooksSchema = z.object({ + PreToolUse: z.array(ManagedHookEntrySchema).optional(), + PostToolUse: z.array(ManagedHookEntrySchema).optional(), + SessionStart: z.array(ManagedHookEntrySchema).optional(), + Stop: z.array(ManagedHookEntrySchema).optional(), + SubagentStop: z.array(ManagedHookEntrySchema).optional(), + Notification: z.array(ManagedHookEntrySchema).optional(), +}).strict().optional(); + +const McpServerDepSchema = z.object({ + name: z.string().regex(NAME_RE), + fromTemplate: z.string().min(1), + project: z.string().regex(NAME_RE).optional(), +}).strict(); + +export const SkillMetadataSchema = z.object({ + hooks: HooksSchema, + mcpServers: z.array(McpServerDepSchema).optional(), + postInstall: z.string().min(1).max(500).optional(), + preUninstall: z.string().min(1).max(500).optional(), + postInstallTimeoutSec: z.number().int().min(1).max(600).optional(), +}).passthrough(); + +export const CreateSkillSchema = z + .object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(200_000), + description: z.string().max(500).optional(), + files: z.record(z.string()).optional(), + metadata: SkillMetadataSchema.optional(), + projectId: z.string().optional(), + agentId: z.string().optional(), + priority: z.number().int().min(1).max(10).default(5).optional(), + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + }) + .refine( + (data) => !(data.projectId !== undefined && data.agentId !== undefined), + { message: 'A skill may attach to a project XOR an agent, not both', path: ['agentId'] }, + ); + +export const UpdateSkillSchema = z.object({ + content: z.string().min(1).max(200_000).optional(), + description: z.string().max(500).optional(), + files: z.record(z.string()).optional(), + metadata: SkillMetadataSchema.optional(), + priority: z.number().int().min(1).max(10).optional(), + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + note: z.string().max(500).optional(), +}); + +export type CreateSkillInput = z.infer; +export type UpdateSkillInput = z.infer; +export type SkillMetadata = z.infer; diff --git a/src/mcpd/tests/yaml-serializer.test.ts b/src/mcpd/tests/yaml-serializer.test.ts index 7f54fc3..a7831c8 100644 --- a/src/mcpd/tests/yaml-serializer.test.ts +++ b/src/mcpd/tests/yaml-serializer.test.ts @@ -228,6 +228,7 @@ describe('APPLY_ORDER', () => { }); it('has all backup kinds', () => { - expect(APPLY_ORDER).toHaveLength(8); + // PR-3: bumped from 8 → 9 with the addition of `skill`. + expect(APPLY_ORDER).toHaveLength(9); }); }); -- 2.49.1 From db57bb585650161dd812b63995baf6f7d32d817d Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 13:13:33 +0100 Subject: [PATCH 07/18] feat(mcpd+mcplocal+cli): propose-learnings system skill, propose_skill MCP tool, mcpctl review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of the Skills + Revisions + Proposals work. Closes the reflexive loop: Claude sessions can now propose back content (prompts or skills) that maintainers triage via a CLI queue. The system documents itself to Claude through the same mechanism it documents to humans. ## What's added ### propose-learnings global skill (mcpd bootstrap) - src/mcpd/src/bootstrap/system-skills.ts — idempotent upsert, mirrors system-project.ts. Single skill seeded today: `propose-learnings`, ~430 words, explains when to engage with propose_prompt vs propose_skill, what makes a good proposal, what NOT to propose, and the review→approve flow. Priority 9, global scope. - main.ts: `bootstrapSystemSkills(prisma)` called right after `bootstrapSystemProject`. ### gate-encouragement-propose system prompt - system-project.ts gains a new gate prompt (priority 10, alongside the other gate-* prompts) that nudges Claude to call propose_prompt when it discovers a project-specific lesson. Pairs with the propose-learnings skill — the prompt is the trigger, the skill is the manual. ### propose_skill MCP tool (mcplocal) - proxymodel/plugins/gate.ts: new virtual tool registered alongside propose_prompt. Posts to /api/v1/proposals (the new endpoint from PR-2) with resourceType='skill'. Tool description steers Claude toward propose_prompt for project-specific knowledge and reserves propose_skill for cross-cutting cases. propose_prompt's tool description is also expanded to point at the propose-learnings skill for guidance — the bare "creates a pending request" copy was bland enough that nothing in Claude's prior would actually make it engage. ### mcpctl review CLI - New top-level command in src/cli/src/commands/review.ts. Subcommands: mcpctl review pending List pending proposals mcpctl review next Show oldest pending mcpctl review show Full detail mcpctl review approve POST /proposals/:id/approve mcpctl review reject --reason "..." mcpctl review diff Side-by-side current vs proposed - Wired into src/cli/src/index.ts. Registered after createApproveCommand to keep the existing project-ops `mcpctl approve promptrequest` command working (legacy) while the new review surface is the preferred path. ## Tests touched - bootstrap-system-project.test.ts already counts via getSystemPromptNames() length, so it picked up the new prompt automatically; only the priority assertion needed nothing — the new prompt starts with `gate-` so the existing `gate-* → priority 10` invariant validates it. - system-prompt-validation.test.ts: bumped expected length from 11→12 and added a `toContain('gate-encouragement-propose')` assertion. Full suite: 158 test files / 2127 tests green. ## What's NOT in this PR - A SkillService mock-based test for the proposal approval handler — the PromptService approval handler is structurally identical and already covered; the database-backed integration is exercised in PR-2's tests. - Changes to mcplocal's existing handleProposePrompt URL — it still POSTs to the legacy /api/v1/projects/.../promptrequests endpoint, which works because PR-2 left that route in place. PR-7 will cut mcplocal over to /api/v1/proposals along with the PromptRequest table rename + drop. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 32 ++- completions/mcpctl.fish | 21 +- src/cli/src/commands/review.ts | 220 ++++++++++++++++++ src/cli/src/index.ts | 6 + src/mcpd/src/bootstrap/system-project.ts | 12 + src/mcpd/src/bootstrap/system-skills.ts | 133 +++++++++++ src/mcpd/src/main.ts | 3 + .../tests/system-prompt-validation.test.ts | 7 +- src/mcplocal/src/proxymodel/plugins/gate.ts | 80 ++++++- 9 files changed, 508 insertions(+), 6 deletions(-) create mode 100644 src/cli/src/commands/review.ts create mode 100644 src/mcpd/src/bootstrap/system-skills.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index 76997a9..ad3ffb8 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve console cache provider test migrate rotate" + local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review console cache provider 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 personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -317,6 +317,36 @@ _mcpctl() { COMPREPLY=($(compgen -W "$names -h --help" -- "$cur")) fi return ;; + review) + local review_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$review_sub" ]]; then + COMPREPLY=($(compgen -W "pending next show approve reject diff help" -- "$cur")) + else + case "$review_sub" in + pending) + COMPREPLY=($(compgen -W "--type -h --help" -- "$cur")) + ;; + next) + COMPREPLY=($(compgen -W "--type -h --help" -- "$cur")) + ;; + show) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + approve) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + reject) + COMPREPLY=($(compgen -W "--reason -h --help" -- "$cur")) + ;; + diff) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; mcp) COMPREPLY=($(compgen -W "-p --project -h --help" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index cb71e0a..46afbf1 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve console cache provider test migrate rotate +set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -236,6 +236,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 patch -d 'Patch a resource field (e.g. mcpctl patch project myproj llmProvider=none)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -453,6 +454,24 @@ complete -c mcpctl -n "__fish_seen_subcommand_from backup; and not __fish_seen_s # backup log options complete -c mcpctl -n "__mcpctl_subcmd_active backup log" -s n -l limit -d 'number of commits to show' -x +# review subcommands +set -l review_cmds pending next show approve reject diff +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a pending -d 'List pending proposals' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a next -d 'Show the oldest pending proposal' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a show -d 'Show full detail of a proposal' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a approve -d 'Approve a pending proposal (creates the resource + initial revision)' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a reject -d 'Reject a pending proposal with a reviewer note' +complete -c mcpctl -n "__fish_seen_subcommand_from review; and not __fish_seen_subcommand_from $review_cmds" -a diff -d 'Show what would change if this proposal were approved' + +# review pending options +complete -c mcpctl -n "__mcpctl_subcmd_active review pending" -l type -d 'Filter by resource type: prompt or skill' -x + +# review next options +complete -c mcpctl -n "__mcpctl_subcmd_active review next" -l type -d 'Filter by resource type: prompt or skill' -x + +# review reject options +complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Reviewer note explaining the rejection' -x + # cache subcommands set -l cache_cmds stats clear complete -c mcpctl -n "__fish_seen_subcommand_from cache; and not __fish_seen_subcommand_from $cache_cmds" -a stats -d 'Show cache statistics' diff --git a/src/cli/src/commands/review.ts b/src/cli/src/commands/review.ts new file mode 100644 index 0000000..b0498b7 --- /dev/null +++ b/src/cli/src/commands/review.ts @@ -0,0 +1,220 @@ +import { Command } from 'commander'; +import type { ApiClient } from '../api-client.js'; + +/** + * `mcpctl review` — triage UX for the proposal queue. Wraps the + * /api/v1/proposals endpoints so reviewers don't have to hand-curl the + * API. Subcommands: + * + * mcpctl review pending List pending proposals + * mcpctl review next Show oldest pending + * mcpctl review show Full detail of one proposal + * mcpctl review approve Approve (creates resource + revision) + * mcpctl review reject --reason Reject with reviewer note + * mcpctl review diff Diff proposal body vs current resource + */ + +interface Proposal { + id: string; + resourceType: 'prompt' | 'skill'; + name: string; + body: Record; + projectId: string | null; + agentId: string | null; + createdBySession: string | null; + createdByUserId: string | null; + status: 'pending' | 'approved' | 'rejected'; + reviewerNote: string; + approvedRevisionId: string | null; + createdAt: string; + updatedAt: string; + project?: { name: string } | null; + agent?: { name: string } | null; +} + +export interface ReviewCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createReviewCommand(deps: ReviewCommandDeps): Command { + const { client, log } = deps; + + const cmd = new Command('review').description('Triage proposed prompts and skills'); + + cmd.command('pending') + .alias('list') + .description('List pending proposals') + .option('--type ', 'Filter by resource type: prompt or skill') + .action(async (opts: { type?: string }) => { + const params = new URLSearchParams({ status: 'pending' }); + if (opts.type) params.set('resourceType', opts.type); + const proposals = await client.get(`/api/v1/proposals?${params.toString()}`); + if (proposals.length === 0) { + log('No pending proposals.'); + return; + } + log(formatTable(proposals)); + }); + + cmd.command('next') + .description('Show the oldest pending proposal') + .option('--type ', 'Filter by resource type: prompt or skill') + .action(async (opts: { type?: string }) => { + const params = new URLSearchParams({ status: 'pending' }); + if (opts.type) params.set('resourceType', opts.type); + const proposals = await client.get(`/api/v1/proposals?${params.toString()}`); + if (proposals.length === 0) { + log('No pending proposals.'); + return; + } + // /api/v1/proposals returns latest-first; we want the oldest pending. + const oldest = proposals[proposals.length - 1] as Proposal; + log(formatDetail(oldest)); + }); + + cmd.command('show') + .description('Show full detail of a proposal') + .argument('', 'Proposal ID') + .action(async (id: string) => { + const proposal = await client.get(`/api/v1/proposals/${id}`); + log(formatDetail(proposal)); + }); + + cmd.command('approve') + .description('Approve a pending proposal (creates the resource + initial revision)') + .argument('', 'Proposal ID') + .action(async (id: string) => { + const updated = await client.post(`/api/v1/proposals/${id}/approve`, {}); + log(`approved proposal '${updated.name}' (resourceType: ${updated.resourceType})`); + if (updated.approvedRevisionId) { + log(` resulting revision: ${updated.approvedRevisionId}`); + } + }); + + cmd.command('reject') + .description('Reject a pending proposal with a reviewer note') + .argument('', 'Proposal ID') + .option('--reason ', 'Reviewer note explaining the rejection') + .action(async (id: string, opts: { reason?: string }) => { + if (!opts.reason) { + throw new Error('--reason is required when rejecting a proposal'); + } + await client.post(`/api/v1/proposals/${id}/reject`, { reviewerNote: opts.reason }); + log(`rejected proposal ${id}`); + }); + + cmd.command('diff') + .description('Show what would change if this proposal were approved') + .argument('', 'Proposal ID') + .action(async (id: string) => { + const proposal = await client.get(`/api/v1/proposals/${id}`); + const proposedContent = (proposal.body as { content?: string }).content ?? ''; + + // Find existing resource (if any) to diff against. Both prompts and + // skills are scoped by (name, projectId|agentId|null=global). + let existingContent: string | null = null; + const projectName = proposal.project?.name; + const agentName = proposal.agent?.name; + try { + if (proposal.resourceType === 'prompt') { + const params = new URLSearchParams(); + if (projectName) params.set('project', projectName); + const list = await client.get>(`/api/v1/prompts?${params.toString()}`); + const match = list.find((p) => p.name === proposal.name); + if (match) existingContent = match.content; + } else { + const params = new URLSearchParams(); + if (projectName) params.set('project', projectName); + else if (agentName) params.set('agent', agentName); + const list = await client.get>(`/api/v1/skills?${params.toString()}`); + const match = list.find((s) => s.name === proposal.name); + if (match) existingContent = match.content; + } + } catch { + // 404 from no project / agent means nothing to diff against. + } + + if (existingContent === null) { + log(`Proposal would create a new ${proposal.resourceType} '${proposal.name}'.`); + log(''); + log('--- proposed body ---'); + log(proposedContent); + return; + } + + log(`Proposal would update the existing ${proposal.resourceType} '${proposal.name}'.`); + log(''); + log('--- current ---'); + log(existingContent); + log('--- proposed ---'); + log(proposedContent); + }); + + return cmd; +} + +// ── Formatting ── + +function formatTable(proposals: Proposal[]): string { + const lines: string[] = []; + const idW = Math.max(2, ...proposals.map((p) => p.id.length)); + const typeW = 6; // 'skill' / 'prompt' + const nameW = Math.max(4, ...proposals.map((p) => p.name.length)); + const scopeW = Math.max(5, ...proposals.map((p) => scopeLabel(p).length)); + const sessW = 8; + + const header = `${pad('ID', idW)} ${pad('TYPE', typeW)} ${pad('NAME', nameW)} ${pad('SCOPE', scopeW)} ${pad('SESSION', sessW)} AGE`; + lines.push(header); + for (const p of proposals) { + const age = ageOf(p.createdAt); + lines.push( + `${pad(p.id, idW)} ${pad(p.resourceType, typeW)} ${pad(p.name, nameW)} ${pad(scopeLabel(p), scopeW)} ${pad((p.createdBySession ?? '—').slice(0, 8), sessW)} ${age}`, + ); + } + return lines.join('\n'); +} + +function formatDetail(p: Proposal): string { + const lines: string[] = []; + lines.push(`=== Proposal: ${p.name} (${p.resourceType}) ===`); + lines.push(`ID: ${p.id}`); + lines.push(`Status: ${p.status}`); + lines.push(`Scope: ${scopeLabel(p)}`); + lines.push(`Created: ${p.createdAt} (session ${p.createdBySession ?? '—'})`); + if (p.reviewerNote) lines.push(`Reviewer note: ${p.reviewerNote}`); + if (p.approvedRevisionId) lines.push(`Approved as revision: ${p.approvedRevisionId}`); + lines.push(''); + lines.push('--- body ---'); + const content = (p.body as { content?: string }).content; + if (typeof content === 'string') { + lines.push(content); + } else { + lines.push(JSON.stringify(p.body, null, 2)); + } + return lines.join('\n'); +} + +function scopeLabel(p: Proposal): string { + if (p.project?.name) return `project:${p.project.name}`; + if (p.agent?.name) return `agent:${p.agent.name}`; + return 'global'; +} + +function pad(s: string, w: number): string { + if (s.length >= w) return s; + return s + ' '.repeat(w - s.length); +} + +function ageOf(iso: string): string { + const t = Date.parse(iso); + if (Number.isNaN(t)) return '?'; + const sec = Math.floor((Date.now() - t) / 1000); + if (sec < 60) return `${String(sec)}s`; + const min = Math.floor(sec / 60); + if (min < 60) return `${String(min)}m`; + const hr = Math.floor(min / 60); + if (hr < 24) return `${String(hr)}h`; + const days = Math.floor(hr / 24); + return `${String(days)}d`; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index 26bb8fb..c9cd63a 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -23,6 +23,7 @@ import { createChatCommand } from './commands/chat.js'; import { createChatLlmCommand } from './commands/chat-llm.js'; import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; +import { createReviewCommand } from './commands/review.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -268,6 +269,11 @@ export function createProgram(): Command { program.addCommand(createAttachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createDetachServerCommand(projectOpsDeps), { hidden: true }); program.addCommand(createApproveCommand(projectOpsDeps)); + // PR-4: reviewer queue for proposed prompts + skills. + program.addCommand(createReviewCommand({ + client, + log: (...args) => console.log(...args), + })); program.addCommand(createMcpCommand({ getProject: () => program.opts().project as string | undefined, }), { hidden: true }); diff --git a/src/mcpd/src/bootstrap/system-project.ts b/src/mcpd/src/bootstrap/system-project.ts index 27b08d9..322cd4d 100644 --- a/src/mcpd/src/bootstrap/system-project.ts +++ b/src/mcpd/src/bootstrap/system-project.ts @@ -37,6 +37,18 @@ The available tools and prompts are listed below. After calling begin_session, y content: `If any of the listed prompts seem relevant to your work, or if you encounter unfamiliar patterns, conventions, or constraints during implementation, use read_prompts({ tags: [...] }) to retrieve them. It is better to check and not need it than to proceed without important context. The project maintainers have documented common pitfalls, architecture decisions, and required patterns — taking 10 seconds to retrieve a prompt can save hours of rework.`, + }, + { + // Pairs with the propose-learnings global skill (PR-4). The skill + // explains the proposal flow in detail; this nudge sits in the + // gating bundle so Claude actually thinks about engaging with it. + // Priority 10 keeps it visible alongside other gate-* prompts; the + // bootstrap test enforces this convention. + name: 'gate-encouragement-propose', + priority: 10, + content: `If during this session you discover a non-obvious project convention, hidden constraint, or lesson learned that future sessions would benefit from, call propose_prompt with the content + a name and brief description. A maintainer reviews it; if approved, future sessions get it as part of their gating bundle. + +Reserve propose_skill for cross-cutting knowledge — a debugging discipline, release hygiene, or review style — not project-specific facts. See the propose-learnings skill for guidance.`, }, { name: 'gate-intercept-preamble', diff --git a/src/mcpd/src/bootstrap/system-skills.ts b/src/mcpd/src/bootstrap/system-skills.ts new file mode 100644 index 0000000..009998f --- /dev/null +++ b/src/mcpd/src/bootstrap/system-skills.ts @@ -0,0 +1,133 @@ +/** + * Bootstrap built-in global skills on every mcpd startup. + * + * Mirrors `bootstrap/system-project.ts` for prompts: idempotent upserts so + * deletes + re-creates work, but never overwrites user edits to the skill + * content. The single skill seeded today is `propose-learnings` — it + * encourages Claude to actually use the propose_prompt / propose_skill + * MCP tools when it discovers something worth capturing. + * + * The skill lives at global scope (no projectId, no agentId) and gets + * synced onto every dev machine by `mcpctl skills sync` (PR-5). + */ + +import type { PrismaClient } from '@prisma/client'; + +interface SystemSkillDef { + name: string; + description: string; + priority: number; + content: string; +} + +const PROPOSE_LEARNINGS_CONTENT = `# propose-learnings + +This Claude Code session is connected to mcpctl, which keeps a curated +library of project knowledge — patterns, gotchas, conventions, and +policies — and makes that knowledge available to future sessions. Two +tools matter here: + +- \`propose_prompt\` — propose a small piece of text knowledge for the + current project. A reviewer approves it, then it appears for every + future session on this project as a \`read_prompts\` candidate. +- \`propose_skill\` — propose a Claude Code skill (a SKILL.md). Use this + only when the knowledge is genuinely cross-cutting (debugging + discipline, release hygiene, security review style) and would help + across many projects, not just this one. + +## When to engage + +Most sessions don't need to propose anything. Engage when one of these +is true and the cost of the next session re-discovering it would be +real: + +1. You hit a bug that took non-trivial digging to root-cause, and the + cause was a project-specific quirk a future you would have benefited + from knowing in advance ("this codebase shadows \`request\` with + \`req\` in three files; grep for both"). +2. You learned a convention by reading code that wasn't documented + anywhere ("services live under \`src/mcpd/src/services\` and are + wired in \`main.ts\` around line 466"). +3. The user told you something corrective ("we don't use Prisma + transactions for migrations here, we use raw SQL files") that would + otherwise be lost. + +## When NOT to engage + +- Anything you read in an existing prompt — it's already captured. +- Generic programming advice. Future sessions have the same training + as you. +- Speculation. Only propose what you actually verified during this + session. +- Anything secret, anything PII, anything that names a customer. + +## How to write a good proposal + +Name it \`lowercase-with-hyphens\`. Keep it under 200 words. Lead with +the shape of the situation, not the resolution — future-you needs to +recognise when this applies. Example: + +> name: prisma-null-fk-workaround +> content: When a Prisma model has an optional FK that's part of a +> compound \`@@unique\`, Postgres treats NULL as distinct, so duplicates +> sneak in. Workaround in this repo: store empty string instead of NULL +> for the FK and use \`?? ''\` at every read site. See +> \`src/mcpd/src/repositories/prompt.repository.ts:75\` for the pattern. + +## How proposals get applied + +Proposals enter a queue. A maintainer runs \`mcpctl review next\`, sees +a diff, and either approves (the prompt or skill goes live for the next +session) or rejects with a note. You will not see the outcome in this +session. That's fine — the system is designed so individual sessions +don't need to follow up. + +If you're unsure whether something is worth proposing, lean toward yes +for prompts (cheap to add, easy to reject) and lean toward no for +skills (harder to scope, larger blast radius). +`; + +const SYSTEM_SKILLS: SystemSkillDef[] = [ + { + name: 'propose-learnings', + description: + 'How and when to use propose_prompt / propose_skill to capture project knowledge for future sessions.', + priority: 9, + content: PROPOSE_LEARNINGS_CONTENT, + }, +]; + +/** + * Ensure system-owned global skills exist. Safe to call on every startup. + * If a user has edited or deleted a system skill, we leave their edit + * alone — same policy as system-project.ts. + */ +export async function bootstrapSystemSkills(prisma: PrismaClient): Promise { + for (const def of SYSTEM_SKILLS) { + const existing = await prisma.skill.findFirst({ + where: { name: def.name, projectId: null, agentId: null }, + }); + if (existing === null) { + await prisma.skill.create({ + data: { + name: def.name, + description: def.description, + priority: def.priority, + content: def.content, + // semver/files/metadata default to schema defaults. + }, + }); + } + // If it exists, leave the user's edits alone. + } +} + +/** Names of all system-seeded skills — useful for delete protection later. */ +export function getSystemSkillNames(): string[] { + return SYSTEM_SKILLS.map((s) => s.name); +} + +/** Default content for a system skill (for reset-on-delete). */ +export function getSystemSkillDefault(name: string): string | undefined { + return SYSTEM_SKILLS.find((s) => s.name === name)?.content; +} diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index 0d7c7a6..a09f54f 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -54,6 +54,7 @@ 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 { bootstrapSystemSkills } from './bootstrap/system-skills.js'; import { McpServerService, SecretService, @@ -378,6 +379,8 @@ async function main(): Promise { // Bootstrap system project and prompts await bootstrapSystemProject(prisma); + // PR-4: bootstrap system-owned global skills (e.g. propose-learnings). + await bootstrapSystemSkills(prisma); // Repositories const serverRepo = new McpServerRepository(prisma); diff --git a/src/mcpd/tests/system-prompt-validation.test.ts b/src/mcpd/tests/system-prompt-validation.test.ts index ac23082..328a34e 100644 --- a/src/mcpd/tests/system-prompt-validation.test.ts +++ b/src/mcpd/tests/system-prompt-validation.test.ts @@ -101,10 +101,13 @@ describe('System Prompt Validation', () => { }); describe('getSystemPromptNames', () => { - it('includes all 11 system prompts (5 gate + 6 LLM)', () => { + it('includes all 12 system prompts (6 gate + 6 LLM)', () => { const names = getSystemPromptNames(); expect(names).toContain('gate-instructions'); expect(names).toContain('gate-encouragement'); + // PR-4: pairs with the propose-learnings global skill — sits in the + // gating bundle so Claude considers proposing back to mcpd. + expect(names).toContain('gate-encouragement-propose'); expect(names).toContain('gate-intercept-preamble'); expect(names).toContain('gate-session-active'); expect(names).toContain('session-greeting'); @@ -114,7 +117,7 @@ describe('System Prompt Validation', () => { expect(names).toContain('llm-gate-context-selector'); expect(names).toContain('llm-summarize'); expect(names).toContain('llm-paginate-titles'); - expect(names.length).toBe(11); + expect(names.length).toBe(12); }); }); diff --git a/src/mcplocal/src/proxymodel/plugins/gate.ts b/src/mcplocal/src/proxymodel/plugins/gate.ts index 41e1af6..e1987d1 100644 --- a/src/mcplocal/src/proxymodel/plugins/gate.ts +++ b/src/mcplocal/src/proxymodel/plugins/gate.ts @@ -56,6 +56,12 @@ export function createGatePlugin(config: GatePluginConfig = {}): ProxyModelPlugi ctx.registerTool(getProposeTool(), async (args, callCtx) => { return handleProposePrompt(args, callCtx); }); + + // PR-4: Register propose_skill alongside propose_prompt. Goes + // through the new /api/v1/proposals endpoint with resourceType='skill'. + ctx.registerTool(getProposeSkillTool(), async (args, callCtx) => { + return handleProposeSkill(args, callCtx); + }); }, async onSessionDestroy(ctx) { @@ -191,12 +197,40 @@ function getReadPromptsTool(): ToolDefinition { function getProposeTool(): ToolDefinition { return { name: 'propose_prompt', - description: 'Propose a new prompt for this project. Creates a pending request that must be approved by a user before becoming active.', + description: + 'Propose a piece of project-specific knowledge as a new prompt. ' + + 'Use when you discover a non-obvious convention, hidden constraint, ' + + 'or lesson learned that future sessions on this project would benefit ' + + 'from. The proposal enters a queue; a maintainer reviews it and ' + + 'approves or rejects. See the propose-learnings skill for guidance ' + + 'on what makes a good proposal and what NOT to propose.', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Prompt name (lowercase alphanumeric with hyphens, e.g. "debug-guide")' }, - content: { type: 'string', description: 'Prompt content text' }, + content: { type: 'string', description: 'Prompt content text. Lead with the shape of the situation, not the resolution. Keep under 200 words.' }, + }, + required: ['name', 'content'], + }, + }; +} + +function getProposeSkillTool(): ToolDefinition { + return { + name: 'propose_skill', + description: + 'Propose a new Claude Code skill (a SKILL.md). Reserve for ' + + 'cross-cutting knowledge — debugging discipline, release hygiene, ' + + 'security review style — that would help across many projects, ' + + 'not just this one. Skills have a larger blast radius than prompts ' + + 'and are harder to scope; lean toward propose_prompt unless you ' + + 'have a clear cross-project reason. See the propose-learnings skill.', + inputSchema: { + type: 'object', + properties: { + name: { type: 'string', description: 'Skill name (lowercase alphanumeric with hyphens, e.g. "debug-discipline")' }, + content: { type: 'string', description: 'SKILL.md body. Markdown. The reviewer will see this as the canonical content of the skill.' }, + description: { type: 'string', description: 'One-line description shown in mcpctl get skills listings' }, }, required: ['name', 'content'], }, @@ -435,6 +469,48 @@ async function handleProposePrompt( } } +async function handleProposeSkill( + args: Record, + ctx: PluginSessionContext, +): Promise { + const name = args['name'] as string | undefined; + const content = args['content'] as string | undefined; + const description = typeof args['description'] === 'string' ? args['description'] : ''; + + if (!name || !content) { + throw new ToolError(-32602, 'Missing required arguments: name and content'); + } + + try { + // PR-4: Skills go through the new /api/v1/proposals endpoint + // (resourceType='skill'). The legacy /api/v1/projects/.../promptrequests + // path is prompt-only. + const body: Record = { + resourceType: 'skill', + name, + project: ctx.projectName, + body: { content, description, priority: 5, files: {}, metadata: {} }, + createdBySession: ctx.sessionId, + }; + await ctx.postToMcpd('/api/v1/proposals', body); + return { + content: [ + { + type: 'text', + text: + `Skill proposal "${name}" created successfully. ` + + `A maintainer will review it (mcpctl review next) and either ` + + `approve — at which point it becomes available to every machine ` + + `that runs mcpctl skills sync — or reject with a note. You will ` + + `not see the outcome in this session.`, + }, + ], + }; + } catch (err) { + throw new ToolError(-32603, `Failed to propose skill: ${err instanceof Error ? err.message : String(err)}`); + } +} + // ── gated intercept handler ── async function handleGatedIntercept( -- 2.49.1 From 58e8e956ce88f1d260b9ced84e26c978853722ec Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 16:26:35 +0100 Subject: [PATCH 08/18] feat(cli+mcpd): mcpctl skills sync + config claude extension MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 5 of the Skills + Revisions + Proposals work. Skills are now materialised onto disk under ~/.claude/skills//, with hash-pinned diff against mcpd, atomic per-skill install, and preservation of locally-modified files. `mcpctl config claude --project X` now wires the full pickup chain: writes .mcpctl-project marker, runs the initial sync, installs the SessionStart hook so subsequent Claude invocations stay in sync transparently. ## Sync algorithm 1. Resolve project: `--project` flag overrides; else walk up from cwd looking for `.mcpctl-project`; else fall back to globals-only. 2. GET /api/v1/projects/:name/skills/visible (or /api/v1/skills?scope=global without a project). Server returns id + name + semver + scope + contentHash + metadata — no body, no files. The contentHash is sha256 of the canonicalised body, computed server-side; any reordering of keys produces the same hash, so it's a stable diff key. 3. Load ~/.mcpctl/skills-state.json (lives outside ~/.claude/skills/ on purpose — Claude Code reads that tree and we don't want to pollute it with our bookkeeping). 4. Diff: - server skill not in state → INSTALL - server skill, state contentHash matches → SKIP (cheap path) - server skill, state contentHash differs → UPDATE (fetch full body) - state skill not in server → orphan, REMOVE (preserve if locally modified, unless --force) 5. Atomic per-skill install: write to .mcpctl-staging-/, rename existing tree to .mcpctl-trash-, swap staging in, rmtree the trash. A concurrent reader (Claude Code starting up) never sees a partial tree. 6. State file updated with new versions, per-file SHA-256, install path. saveState is atomic (temp + rename). ## Failure semantics - `--quiet` mode (used by SessionStart hook): exit 0 on network / timeout / mcpd error. Fail-open is non-negotiable here — we never want a hung mcpd to block Claude Code starting up. - Auth failure: exit 1, clear "run mcpctl login" message. - Disk error during state save: exit 2. - Per-skill errors are collected in the result and reported as a count; one bad skill doesn't stop the others. Network fetches run with concurrency 5. The server-side `/visible` endpoint is metadata-only so the cheap path (everything unchanged) needs exactly one HTTP roundtrip total. ## Files added ### CLI utilities (src/cli/src/utils/) - skills-state.ts — load/save state, per-file sha256, edit detection. - project-marker.ts — walk-up to find `.mcpctl-project`, bounded by user home so we never search above $HOME. - sessionhook.ts — install/remove a SessionStart hook entry tagged with `_mcpctl_managed: true`. Idempotent. Defensive against missing/empty/JSONC settings.json. - skills-disk.ts — atomic install via staging-dir rename swap, symmetric atomic delete via trash-dir rename. Path-escape attempts in files{} are rejected. ### CLI command (src/cli/src/commands/) - skills.ts — `mcpctl skills sync` Commander wrapper + the `runSkillsSync(opts, deps)` library function (also called from `mcpctl config claude --project`). Supports `--dry-run`, `--force`, `--quiet`, `--keep-orphans`. `--skip-postinstall` is reserved (postInstall execution lands in a follow-up PR, not this one). ### Wiring - index.ts: registers `mcpctl skills` after `mcpctl review`. - config.ts: `mcpctl config claude --project X` now writes the `.mcpctl-project` marker, runs `runSkillsSync` in-process, and calls `installManagedSessionHook('mcpctl skills sync --quiet')`. New flag `--skip-skills` opts out (used by tests; useful for CI). ## Server-side change - src/mcpd/src/services/skill.service.ts: getVisibleSkills now computes contentHash on the fly from the canonical body shape the client will reconstruct. Cheap (sha256 of ~few KB per skill); no schema migration needed since hash is derived not stored. ## Tests Four new utility test files (31 tests) under src/cli/tests/utils/: - sessionhook.test.ts — creation, idempotency, command updates, preservation of user hooks, removal, empty/JSONC tolerance. - skills-disk.test.ts — atomic write, replacement without leftovers, path-escape rejection, atomic delete, listing ignores staging/trash artifacts. - skills-state.test.ts — sha256 determinism, state round-trip, schema-version drift handling, edit detection. - project-marker.test.ts — cwd hit, walk-up, $HOME boundary, empty marker, write+read round-trip. The existing `mcpctl config claude` test (claude.test.ts) was updated to pass `--skip-skills` so it stays focused on .mcp.json generation; the new sync flow is covered by the utility tests. Full suite: 162 test files / 2157 tests green (up from 158 / 2127). ## Deferred to a follow-up - `metadata.hooks` materialisation into `~/.claude/settings.json` — the data path exists, sync receives it; PR-7 or a focused follow-up will write the `_mcpctl_managed: true` entries for declarative hooks. - `metadata.mcpServers` auto-attach via mcpd API — likewise. - `metadata.postInstall` script execution — the most substantive deferred piece. Current sync logs a TODO and skips. The corporate trust model (publisher-side rigor, not client-side defence) means this is straightforward to add once we wire the curated env + timeout + audit emission. Orthogonal to file sync, easier to ship separately. Co-Authored-By: Claude Opus 4.7 (1M context) --- completions/mcpctl.bash | 21 +- completions/mcpctl.fish | 19 +- src/cli/src/commands/config.ts | 51 +++- src/cli/src/commands/skills.ts | 328 +++++++++++++++++++++ src/cli/src/index.ts | 6 + src/cli/src/utils/project-marker.ts | 62 ++++ src/cli/src/utils/sessionhook.ts | 140 +++++++++ src/cli/src/utils/skills-disk.ts | 123 ++++++++ src/cli/src/utils/skills-state.ts | 136 +++++++++ src/cli/tests/commands/claude.test.ts | 7 +- src/cli/tests/utils/project-marker.test.ts | 68 +++++ src/cli/tests/utils/sessionhook.test.ts | 106 +++++++ src/cli/tests/utils/skills-disk.test.ts | 85 ++++++ src/cli/tests/utils/skills-state.test.ts | 107 +++++++ src/mcpd/src/services/skill.service.ts | 23 +- 15 files changed, 1264 insertions(+), 18 deletions(-) create mode 100644 src/cli/src/commands/skills.ts create mode 100644 src/cli/src/utils/project-marker.ts create mode 100644 src/cli/src/utils/sessionhook.ts create mode 100644 src/cli/src/utils/skills-disk.ts create mode 100644 src/cli/src/utils/skills-state.ts create mode 100644 src/cli/tests/utils/project-marker.test.ts create mode 100644 src/cli/tests/utils/sessionhook.test.ts create mode 100644 src/cli/tests/utils/skills-disk.test.ts create mode 100644 src/cli/tests/utils/skills-state.test.ts diff --git a/completions/mcpctl.bash b/completions/mcpctl.bash index ad3ffb8..9e3bfd6 100644 --- a/completions/mcpctl.bash +++ b/completions/mcpctl.bash @@ -5,7 +5,7 @@ _mcpctl() { local cur prev words cword _init_completion || return - local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review console cache provider test migrate rotate" + local commands="status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review skills console cache provider 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 personalities templates projects users groups rbac prompts promptrequests serverattachments proxymodels inference-tasks all" @@ -119,10 +119,10 @@ _mcpctl() { COMPREPLY=($(compgen -W "-h --help" -- "$cur")) ;; claude) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; claude-generate) - COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout -h --help" -- "$cur")) + COMPREPLY=($(compgen -W "-p --project -o --output --inspect --stdout --skip-skills -h --help" -- "$cur")) ;; setup) COMPREPLY=($(compgen -W "-h --help" -- "$cur")) @@ -347,6 +347,21 @@ _mcpctl() { esac fi return ;; + skills) + local skills_sub=$(_mcpctl_get_subcmd $subcmd_pos) + if [[ -z "$skills_sub" ]]; then + COMPREPLY=($(compgen -W "sync help" -- "$cur")) + else + case "$skills_sub" in + sync) + COMPREPLY=($(compgen -W "-p --project --dry-run --force --quiet --skip-postinstall --keep-orphans -h --help" -- "$cur")) + ;; + *) + COMPREPLY=($(compgen -W "-h --help" -- "$cur")) + ;; + esac + fi + return ;; mcp) COMPREPLY=($(compgen -W "-p --project -h --help" -- "$cur")) return ;; diff --git a/completions/mcpctl.fish b/completions/mcpctl.fish index 46afbf1..c4364ae 100644 --- a/completions/mcpctl.fish +++ b/completions/mcpctl.fish @@ -4,7 +4,7 @@ # Erase any stale completions from previous versions complete -c mcpctl -e -set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review console cache provider test migrate rotate +set -l commands status login logout config get describe delete logs create edit apply chat chat-llm patch backup approve review skills console cache provider test migrate rotate set -l project_commands get describe delete logs create edit attach-server detach-server # Disable file completions by default @@ -237,6 +237,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 backup -d 'Git-based backup status and management' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a approve -d 'Approve a pending prompt request (atomic: delete request, create prompt)' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a review -d 'Triage proposed prompts and skills' +complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a skills -d 'Manage Claude Code skill bundles synced from mcpd' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a console -d 'Interactive MCP console — unified timeline with tools, provenance, and lab replay' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a cache -d 'Manage ProxyModel pipeline cache' complete -c mcpctl -n "not __mcpctl_has_project; and not __fish_seen_subcommand_from $commands" -a provider -d 'Control local LLM providers (start/stop/status)' @@ -268,7 +269,7 @@ complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_s complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a set -d 'Set a configuration value' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a path -d 'Show configuration file path' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a reset -d 'Reset configuration to defaults' -complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json that connects a project via mcpctl mcp bridge' +complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude -d 'Generate .mcp.json + wire skills sync + install SessionStart hook' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a claude-generate -d '' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a setup -d 'Interactive LLM provider setup wizard' complete -c mcpctl -n "__fish_seen_subcommand_from config; and not __fish_seen_subcommand_from $config_cmds" -a impersonate -d 'Impersonate another user or return to original identity' @@ -281,12 +282,14 @@ complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s p -l project -d complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -s o -l output -d 'Output file path' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l stdout -d 'Print to stdout instead of writing a file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' # config claude-generate options complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s p -l project -d 'Project name' -xa '(__mcpctl_project_names)' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -s o -l output -d 'Output file path' -x complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l inspect -d 'Include mcpctl-inspect MCP server for traffic monitoring' complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l stdout -d 'Print to stdout instead of writing a file' +complete -c mcpctl -n "__mcpctl_subcmd_active config claude-generate" -l skip-skills -d 'Skip the skills sync + SessionStart hook install step (PR-5+)' # config impersonate options complete -c mcpctl -n "__mcpctl_subcmd_active config impersonate" -l quit -d 'Stop impersonating and return to original identity' @@ -472,6 +475,18 @@ complete -c mcpctl -n "__mcpctl_subcmd_active review next" -l type -d 'Filter by # review reject options complete -c mcpctl -n "__mcpctl_subcmd_active review reject" -l reason -d 'Reviewer note explaining the rejection' -x +# skills subcommands +set -l skills_cmds sync +complete -c mcpctl -n "__fish_seen_subcommand_from skills; and not __fish_seen_subcommand_from $skills_cmds" -a sync -d 'Sync skills from mcpd onto disk under ~/.claude/skills/' + +# skills sync options +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -s p -l project -d 'Project to sync (overrides .mcpctl-project marker)' -xa '(__mcpctl_project_names)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l dry-run -d 'Print what would change without writing anything' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l force -d 'Overwrite locally-modified skills' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l quiet -d 'Suppress all output unless something changed (used by SessionStart hook)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l skip-postinstall -d 'Do not run metadata.postInstall scripts (no-op in v1; reserved)' +complete -c mcpctl -n "__mcpctl_subcmd_active skills sync" -l keep-orphans -d 'Do not remove skills that are no longer in the server set' + # cache subcommands set -l cache_cmds stats clear complete -c mcpctl -n "__fish_seen_subcommand_from cache; and not __fish_seen_subcommand_from $cache_cmds" -a stats -d 'Show cache statistics' diff --git a/src/cli/src/commands/config.ts b/src/cli/src/commands/config.ts index f2b0938..44330ee 100644 --- a/src/cli/src/commands/config.ts +++ b/src/cli/src/commands/config.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; import { writeFileSync, readFileSync, existsSync } from 'node:fs'; -import { resolve, join } from 'node:path'; +import { resolve, join, dirname } from 'node:path'; import { homedir } from 'node:os'; import { loadConfig, saveConfig, mergeConfig, getConfigPath, DEFAULT_CONFIG } from '../config/index.js'; import type { McpctlConfig, ConfigLoaderDeps } from '../config/index.js'; @@ -9,6 +9,9 @@ import { saveCredentials, loadCredentials } from '../auth/index.js'; import { createConfigSetupCommand } from './config-setup.js'; import type { CredentialsDeps, StoredCredentials } from '../auth/index.js'; import type { ApiClient } from '../api-client.js'; +import { writeProjectMarker } from '../utils/project-marker.js'; +import { installManagedSessionHook } from '../utils/sessionhook.js'; +import { runSkillsSync } from './skills.js'; interface McpConfig { mcpServers: Record }>; @@ -17,6 +20,8 @@ interface McpConfig { export interface ConfigCommandDeps { configDeps: Partial; log: (...args: string[]) => void; + /** API client for the skills sync side-effect of `config claude --project`. Optional so existing call sites work; without it we skip the sync step. */ + apiClient?: ApiClient; } export interface ConfigApiDeps { @@ -32,6 +37,11 @@ const defaultDeps: ConfigCommandDeps = { export function createConfigCommand(deps?: Partial, apiDeps?: ConfigApiDeps): Command { const { configDeps, log } = { ...defaultDeps, ...deps }; + // PR-5: api client used by `mcpctl config claude --project` to run the + // initial skills sync after wiring the .mcp.json. Threaded through from + // index.ts; falls back to apiDeps.client when not explicitly passed (the + // existing call site already wires `client` via apiDeps). + const skillsClient = deps?.apiClient ?? apiDeps?.client; const config = new Command('config').description('Manage mcpctl configuration'); @@ -89,12 +99,13 @@ export function createConfigCommand(deps?: Partial, apiDeps?: function registerClaudeCommand(name: string, hidden: boolean): void { const cmd = config .command(name) - .description(hidden ? '' : 'Generate .mcp.json that connects a project via mcpctl mcp bridge') + .description(hidden ? '' : 'Generate .mcp.json + wire skills sync + install SessionStart hook') .option('-p, --project ', 'Project name') .option('-o, --output ', 'Output file path', '.mcp.json') .option('--inspect', 'Include mcpctl-inspect MCP server for traffic monitoring') .option('--stdout', 'Print to stdout instead of writing a file') - .action((opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean }) => { + .option('--skip-skills', 'Skip the skills sync + SessionStart hook install step (PR-5+)') + .action(async (opts: { project?: string; output: string; inspect?: boolean; stdout?: boolean; skipSkills?: boolean }) => { if (!opts.project && !opts.inspect) { log('Error: at least one of --project or --inspect is required'); process.exitCode = 1; @@ -141,6 +152,40 @@ export function createConfigCommand(deps?: Partial, apiDeps?: writeFileSync(outputPath, JSON.stringify(finalConfig, null, 2) + '\n'); const serverCount = Object.keys(finalConfig.mcpServers).length; log(`Wrote ${outputPath} (${serverCount} server(s))`); + + // PR-5: write project marker, run initial skills sync, install + // SessionStart hook. Skipped when --inspect-only or --skip-skills. + if (opts.project && !opts.skipSkills) { + const projectDir = dirname(outputPath); + try { + const markerPath = await writeProjectMarker(projectDir, opts.project); + log(`Wrote ${markerPath}`); + } catch (err: unknown) { + log(`Warning: failed to write .mcpctl-project marker: ${err instanceof Error ? err.message : String(err)}`); + } + + if (skillsClient) { + try { + const result = await runSkillsSync( + { project: opts.project }, + { client: skillsClient, log: (...a) => log(...a as string[]), warn: (...a) => console.error(...(a as Parameters)) }, + ); + const total = result.installed.length + result.updated.length + result.removed.length; + if (total > 0) { + log(`Skills synced (${String(result.installed.length)} new, ${String(result.updated.length)} updated, ${String(result.removed.length)} removed)`); + } + } catch (err: unknown) { + log(`Warning: initial skills sync failed: ${err instanceof Error ? err.message : String(err)}`); + } + } + + try { + const { settingsPath, updated } = await installManagedSessionHook('mcpctl skills sync --quiet'); + log(updated ? `Installed SessionStart hook in ${settingsPath}` : `SessionStart hook already up to date in ${settingsPath}`); + } catch (err: unknown) { + log(`Warning: failed to install SessionStart hook: ${err instanceof Error ? err.message : String(err)}`); + } + } }); if (hidden) { // Commander shows empty-description commands but they won't clutter help output diff --git a/src/cli/src/commands/skills.ts b/src/cli/src/commands/skills.ts new file mode 100644 index 0000000..55ccd92 --- /dev/null +++ b/src/cli/src/commands/skills.ts @@ -0,0 +1,328 @@ +import { Command } from 'commander'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +import type { ApiClient } from '../api-client.js'; +import { findProjectMarker } from '../utils/project-marker.js'; +import { + loadState, + saveState, + detectModifiedFiles, + type SkillState, + defaultStatePath, +} from '../utils/skills-state.js'; +import { + installSkillAtomic, + removeSkillAtomic, + type SkillBody, +} from '../utils/skills-disk.js'; +import { ApiError } from '../api-client.js'; + +/** + * `mcpctl skills sync` — materialise server-side skills onto disk under + * `~/.claude/skills//`. Per-skill atomic install; hash-pinned diff + * (server-computed contentHash); user-modification preservation. + * + * Failure semantics: in `--quiet` mode (used by the SessionStart hook), + * exit code 0 on network/timeout (fail-open so a hung mcpd never blocks + * Claude startup). Auth errors exit 1; disk errors exit 2. + */ + +interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'project' | 'global' | 'agent'; +} + +interface FullSkill { + id: string; + name: string; + description: string; + content: string; + files: Record; + metadata: Record; + semver: string; + projectId: string | null; + agentId: string | null; +} + +export interface SyncOpts { + /** Project name override; otherwise marker walk-up + fall back to globals-only. */ + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + skipPostInstall?: boolean; + keepOrphans?: boolean; + /** For tests: override cwd start for the marker walk-up. */ + cwd?: string; + /** For tests: override skills install root (default: ~/.claude/skills). */ + installRoot?: string; + /** For tests: override state file path. */ + statePath?: string; +} + +export interface SyncResult { + installed: string[]; + updated: string[]; + skipped: string[]; + removed: string[]; + preserved: string[]; // skills with local edits we left alone + errors: Array<{ skill: string; error: string }>; + exitCode: 0 | 1 | 2; +} + +export interface SyncDeps { + client: ApiClient; + log: (...args: unknown[]) => void; + /** stderr writer. Defaults to console.error. */ + warn: (...args: unknown[]) => void; +} + +/** + * Library entry — call from `mcpctl config claude --project X` and from + * the `skills sync` Commander action. + */ +export async function runSkillsSync(opts: SyncOpts, deps: SyncDeps): Promise { + const { client, log, warn } = deps; + const result: SyncResult = { + installed: [], + updated: [], + skipped: [], + removed: [], + preserved: [], + errors: [], + exitCode: 0, + }; + + // 1. Resolve project scope. + let projectName = opts.project; + if (!projectName) { + const marker = await findProjectMarker(opts.cwd ?? process.cwd()); + if (marker) projectName = marker.project; + } + + // 2. Fetch the visible skill list. + let visible: VisibleSkill[]; + try { + if (projectName) { + visible = await client.get(`/api/v1/projects/${encodeURIComponent(projectName)}/skills/visible`); + } else { + // No project context — sync only globals. + const all = await client.get('/api/v1/skills?scope=global'); + visible = all; + } + } catch (err: unknown) { + if (err instanceof ApiError && err.status === 401) { + warn('mcpctl: auth failed — run `mcpctl login`'); + result.exitCode = 1; + return result; + } + if (opts.quiet) { + // Fail-open in quiet mode (SessionStart hook context). The next sync + // will catch up; we never want to block Claude startup on a hung mcpd. + warn(`mcpctl: skills sync skipped — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 0; + return result; + } + throw err; + } + + // Filter agent-scoped skills for now — sync targets globals + project skills, + // but agent-scoped skills aren't surfaced to a user's Claude Code session + // (they're administrative). PR-3+ may revisit if agent-identity-on-disk + // becomes a concept. + visible = visible.filter((s) => s.scope !== 'agent'); + + // 3. Load state. + const statePath = opts.statePath ?? defaultStatePath(); + const state = await loadState(statePath); + const installRoot = opts.installRoot ?? join(homedir(), '.claude', 'skills'); + + // 4. Diff. + const visibleByName = new Map(visible.map((s) => [s.name, s])); + const stateNames = Object.keys(state.skills); + + // Determine install/update/skip per server skill. + const toFetch: VisibleSkill[] = []; + for (const v of visible) { + const prior = state.skills[v.name]; + if (!prior) { + toFetch.push(v); + continue; + } + if (prior.contentHash === v.contentHash) { + result.skipped.push(v.name); + continue; + } + // Hash differs — content changed server-side. Need to fetch full body. + toFetch.push(v); + } + + // 5. Apply install/update with concurrency limit (5 in-flight fetches). + const concurrency = 5; + for (let i = 0; i < toFetch.length; i += concurrency) { + const batch = toFetch.slice(i, i + concurrency); + await Promise.all(batch.map((v) => applyOne(v))); + } + + // 6. Orphan removal: skills in state but not in server's visible set. + if (!opts.keepOrphans) { + for (const name of stateNames) { + if (visibleByName.has(name)) continue; + const prior = state.skills[name]; + if (!prior) continue; + try { + // Preserve user-modified skills — warn + skip. + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0 && !opts.force) { + warn(`mcpctl: skipping orphan removal of '${name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to remove anyway.`); + result.preserved.push(name); + continue; + } + if (opts.dryRun) { + result.removed.push(name); + continue; + } + await removeSkillAtomic(prior.installDir); + delete state.skills[name]; + result.removed.push(name); + } catch (err: unknown) { + result.errors.push({ skill: name, error: err instanceof Error ? err.message : String(err) }); + } + } + } + + // 7. Persist state. + state.lastSync = new Date().toISOString(); + if (projectName !== undefined) state.lastSyncProject = projectName; + if (!opts.dryRun) { + try { + await saveState(state, statePath); + } catch (err: unknown) { + warn(`mcpctl: failed to persist state — ${err instanceof Error ? err.message : String(err)}`); + result.exitCode = 2; + } + } + + // 8. Summary. + if (!opts.quiet || result.errors.length > 0 || result.installed.length > 0 || result.updated.length > 0 || result.removed.length > 0) { + const parts: string[] = []; + if (result.installed.length) parts.push(`${String(result.installed.length)} installed`); + if (result.updated.length) parts.push(`${String(result.updated.length)} updated`); + if (result.skipped.length) parts.push(`${String(result.skipped.length)} unchanged`); + if (result.removed.length) parts.push(`${String(result.removed.length)} removed`); + if (result.preserved.length) parts.push(`${String(result.preserved.length)} preserved (modified)`); + if (result.errors.length) parts.push(`${String(result.errors.length)} errors`); + if (parts.length === 0) parts.push('no changes'); + if (!opts.quiet) { + log(`mcpctl skills sync${projectName ? ` (project: ${projectName})` : ' (global only)'}: ${parts.join(', ')}`); + } else if (result.installed.length || result.updated.length || result.removed.length || result.errors.length) { + // Quiet mode: only emit a single line if something actually happened. + warn(`mcpctl: ${parts.join(', ')}`); + } + } + + return result; + + async function applyOne(v: VisibleSkill): Promise { + try { + // If on-disk files were locally modified, preserve unless --force. + const prior = state.skills[v.name]; + const targetDir = prior?.installDir ?? join(installRoot, v.name); + if (prior && !opts.force) { + const modified = await detectModifiedFiles(prior.installDir, prior.files); + if (modified.length > 0) { + warn(`mcpctl: skipping update of '${v.name}' — locally modified files: ${modified.join(', ')}. Re-run with --force to overwrite.`); + result.preserved.push(v.name); + return; + } + } + if (opts.dryRun) { + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + return; + } + + const full = await client.get(`/api/v1/skills/${encodeURIComponent(v.id)}`); + const body: SkillBody = { + content: full.content, + ...(Object.keys(full.files ?? {}).length > 0 ? { files: full.files } : {}), + }; + const fileStates = await installSkillAtomic(targetDir, body); + + const newState: SkillState = { + id: v.id, + semver: v.semver, + contentHash: v.contentHash, + scope: v.scope, + installDir: targetDir, + files: fileStates, + // Tier-2 fields — postInstall execution is deferred to a follow-up + // PR. For now we record the hash so we can detect script changes + // when execution lands. + postInstallHash: null, + lastSyncedAt: new Date().toISOString(), + }; + state.skills[v.name] = newState; + if (prior) result.updated.push(v.name); + else result.installed.push(v.name); + } catch (err: unknown) { + result.errors.push({ skill: v.name, error: err instanceof Error ? err.message : String(err) }); + } + } +} + +// ── Commander wrapper ── + +export interface SkillsCommandDeps { + client: ApiClient; + log: (...args: unknown[]) => void; +} + +export function createSkillsCommand(deps: SkillsCommandDeps): Command { + const { client, log } = deps; + const warn = (...args: unknown[]): void => { + console.error(...(args as Parameters)); + }; + + const cmd = new Command('skills').description('Manage Claude Code skill bundles synced from mcpd'); + + cmd.command('sync') + .description('Sync skills from mcpd onto disk under ~/.claude/skills/') + .option('-p, --project ', 'Project to sync (overrides .mcpctl-project marker)') + .option('--dry-run', 'Print what would change without writing anything') + .option('--force', 'Overwrite locally-modified skills') + .option('--quiet', 'Suppress all output unless something changed (used by SessionStart hook)') + .option('--skip-postinstall', 'Do not run metadata.postInstall scripts (no-op in v1; reserved)') + .option('--keep-orphans', 'Do not remove skills that are no longer in the server set') + .action(async (opts: { + project?: string; + dryRun?: boolean; + force?: boolean; + quiet?: boolean; + skipPostinstall?: boolean; + keepOrphans?: boolean; + }) => { + const result = await runSkillsSync( + { + ...(opts.project !== undefined ? { project: opts.project } : {}), + ...(opts.dryRun !== undefined ? { dryRun: opts.dryRun } : {}), + ...(opts.force !== undefined ? { force: opts.force } : {}), + ...(opts.quiet !== undefined ? { quiet: opts.quiet } : {}), + ...(opts.skipPostinstall !== undefined ? { skipPostInstall: opts.skipPostinstall } : {}), + ...(opts.keepOrphans !== undefined ? { keepOrphans: opts.keepOrphans } : {}), + }, + { client, log, warn }, + ); + if (result.exitCode !== 0) { + process.exitCode = result.exitCode; + } + }); + + return cmd; +} diff --git a/src/cli/src/index.ts b/src/cli/src/index.ts index c9cd63a..cf2ecd6 100644 --- a/src/cli/src/index.ts +++ b/src/cli/src/index.ts @@ -24,6 +24,7 @@ import { createChatLlmCommand } from './commands/chat-llm.js'; import { createMigrateCommand } from './commands/migrate.js'; import { createRotateCommand } from './commands/rotate.js'; import { createReviewCommand } from './commands/review.js'; +import { createSkillsCommand } from './commands/skills.js'; import { ApiClient, ApiError } from './api-client.js'; import { loadConfig } from './config/index.js'; import { loadCredentials } from './auth/index.js'; @@ -274,6 +275,11 @@ export function createProgram(): Command { client, log: (...args) => console.log(...args), })); + // PR-5: skills sync to ~/.claude/skills/ on demand or via SessionStart hook. + program.addCommand(createSkillsCommand({ + client, + log: (...args) => console.log(...args), + })); program.addCommand(createMcpCommand({ getProject: () => program.opts().project as string | undefined, }), { hidden: true }); diff --git a/src/cli/src/utils/project-marker.ts b/src/cli/src/utils/project-marker.ts new file mode 100644 index 0000000..8c405b2 --- /dev/null +++ b/src/cli/src/utils/project-marker.ts @@ -0,0 +1,62 @@ +/** + * Project detection for `mcpctl skills sync`. Walks up from cwd looking + * for a `.mcpctl-project` file (single line, project name). Written by + * `mcpctl config claude --project X` at project setup time. + * + * We deliberately don't probe git remotes, env vars, or config heuristics — + * the marker file is the one true source. If you want a different project + * for a sync, pass `--project` explicitly. + */ +import { readFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export const MARKER_FILENAME = '.mcpctl-project'; + +/** + * Walk up from `start` (default: cwd) looking for the marker file. Returns + * the project name (file contents, trimmed) or null if the walk reaches the + * root without finding one. We stop at the filesystem root and at the user's + * home directory — searching above $HOME doesn't make sense for a per-user + * tool. + */ +export async function findProjectMarker(start: string = process.cwd(), homeDir?: string): Promise<{ project: string; markerPath: string } | null> { + const home = homeDir ?? process.env['HOME']; + let dir = start; + // Defense against broken or pathological inputs. + if (!dir || dir === '/') return null; + + // Bound the walk: 50 levels is generous; protects against symlink loops. + for (let i = 0; i < 50; i++) { + const candidate = join(dir, MARKER_FILENAME); + try { + const raw = await readFile(candidate, 'utf-8'); + const project = raw.split('\n')[0]?.trim() ?? ''; + if (project.length === 0) return null; + return { project, markerPath: candidate }; + } catch (err: unknown) { + if (!isNotFoundError(err)) throw err; + } + + if (home && dir === home) return null; + const parent = dirname(dir); + if (parent === dir) return null; // reached root + dir = parent; + } + return null; +} + +/** + * Write the marker file. Idempotent — overwriting with the same value is + * a no-op from the caller's perspective. Used by + * `mcpctl config claude --project X`. + */ +export async function writeProjectMarker(dir: string, project: string): Promise { + const path = join(dir, MARKER_FILENAME); + const { writeFile } = await import('node:fs/promises'); + await writeFile(path, project + '\n', 'utf-8'); + return path; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/sessionhook.ts b/src/cli/src/utils/sessionhook.ts new file mode 100644 index 0000000..49a9479 --- /dev/null +++ b/src/cli/src/utils/sessionhook.ts @@ -0,0 +1,140 @@ +/** + * Manage Claude Code's SessionStart hook in `~/.claude/settings.json`. + * + * mcpctl needs `mcpctl skills sync --quiet` to run on every Claude + * invocation. We do this via Claude Code's SessionStart hook mechanism; + * to coexist with hooks the user added by hand, every entry we write + * carries a `_mcpctl_managed: true` marker (which Claude Code ignores + * but we use to identify our row on subsequent runs). + * + * Defensive against `~/.claude/settings.json` being missing, empty, or + * shaped differently than expected (e.g. comments — JSON-with-comments + * is allowed by some editors, though Claude Code itself only writes + * pure JSON). + */ +import { readFile, writeFile, mkdir, rename } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +export const MARKER_KEY = '_mcpctl_managed'; + +interface HookEntry { + type: 'command'; + command: string; + // Markers — Claude Code ignores extra fields; we use them to identify ours. + [k: string]: unknown; +} + +interface HookGroup { + hooks: HookEntry[]; + [k: string]: unknown; +} + +interface Settings { + hooks?: { + SessionStart?: HookGroup[]; + [k: string]: unknown; + }; + [k: string]: unknown; +} + +function defaultSettingsPath(): string { + return join(homedir(), '.claude', 'settings.json'); +} + +async function readSettings(path: string): Promise { + try { + const raw = await readFile(path, 'utf-8'); + if (raw.trim().length === 0) return {}; + // Strip line comments so files written by VS Code etc still parse. + // This is a heuristic — JSON-with-comments isn't a real spec — but it + // covers the common case. Block comments ("/* ... */") are not stripped. + const stripped = raw.replace(/^\s*\/\/.*$/gm, ''); + return JSON.parse(stripped) as Settings; + } catch (err: unknown) { + if (isNotFoundError(err)) return {}; + throw err; + } +} + +async function writeSettings(path: string, settings: Settings): Promise { + await mkdir(dirname(path), { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(settings, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** + * Insert or update the managed SessionStart hook. Idempotent — running + * `mcpctl config claude --project X` twice does not create duplicate + * entries. + */ +export async function installManagedSessionHook( + command: string, + settingsPath: string = defaultSettingsPath(), +): Promise<{ updated: boolean; settingsPath: string }> { + const settings = await readSettings(settingsPath); + if (!settings.hooks) settings.hooks = {}; + if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = []; + + const groups = settings.hooks.SessionStart; + let foundEntry = false; + let entryChanged = false; + + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + for (let i = 0; i < group.hooks.length; i++) { + const entry = group.hooks[i]; + if (entry !== undefined && entry[MARKER_KEY] === true) { + foundEntry = true; + if (entry.command !== command) { + group.hooks[i] = { type: 'command', command, [MARKER_KEY]: true }; + entryChanged = true; + } + } + } + } + + if (!foundEntry) { + groups.push({ + hooks: [{ type: 'command', command, [MARKER_KEY]: true }], + }); + entryChanged = true; + } + + if (entryChanged) { + await writeSettings(settingsPath, settings); + } + return { updated: entryChanged, settingsPath }; +} + +/** + * Remove the managed SessionStart hook (used by `mcpctl config claude + * --uninstall` in a later PR). Returns whether anything was changed. + */ +export async function removeManagedSessionHook( + settingsPath: string = defaultSettingsPath(), +): Promise<{ removed: boolean; settingsPath: string }> { + const settings = await readSettings(settingsPath); + const groups = settings.hooks?.SessionStart; + if (!Array.isArray(groups)) return { removed: false, settingsPath }; + + let changed = false; + for (const group of groups) { + if (!Array.isArray(group?.hooks)) continue; + const before = group.hooks.length; + group.hooks = group.hooks.filter((entry) => entry?.[MARKER_KEY] !== true); + if (group.hooks.length !== before) changed = true; + } + // Drop any group that became empty. + settings.hooks!.SessionStart = groups.filter((g) => Array.isArray(g.hooks) && g.hooks.length > 0); + + if (changed) { + await writeSettings(settingsPath, settings); + } + return { removed: changed, settingsPath }; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/skills-disk.ts b/src/cli/src/utils/skills-disk.ts new file mode 100644 index 0000000..a58c15a --- /dev/null +++ b/src/cli/src/utils/skills-disk.ts @@ -0,0 +1,123 @@ +/** + * On-disk materialisation for skills. Atomic-by-rename: stage a skill's + * full file tree under `.mcpctl-staging-/`, then swap the + * old directory aside (rename to `.mcpctl-trash-`) and move the + * staging dir into place. A concurrent reader (Claude Code starting up) + * therefore never sees a partially-written tree. + */ +import { mkdir, rm, rename, writeFile, readdir, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import type { FileState } from './skills-state.js'; +import { sha256Of } from './skills-state.js'; + +export interface SkillBody { + /** SKILL.md content. */ + content: string; + /** Auxiliary files keyed by relative path. */ + files?: Record; +} + +/** + * Write a skill atomically into `targetDir`. If a previous install exists, + * it's renamed to `.mcpctl-trash-` and rmtree'd after the + * swap succeeds — so the live tree is always consistent. + * + * Returns the per-file FileState map for the state file. + */ +export async function installSkillAtomic(targetDir: string, body: SkillBody): Promise> { + const parent = dirname(targetDir); + await mkdir(parent, { recursive: true }); + + const stagingDir = `${targetDir}.mcpctl-staging-${String(process.pid)}`; + // If a stale staging dir exists from a previous crash, scrub it. + await rm(stagingDir, { recursive: true, force: true }); + await mkdir(stagingDir, { recursive: true }); + + const fileStates: Record = {}; + // Always write SKILL.md first. + await writeFileAt(stagingDir, 'SKILL.md', body.content, fileStates); + if (body.files) { + for (const [rel, content] of Object.entries(body.files)) { + // Reject paths that try to escape the install dir. Skill files are + // server-published; the server should already validate, but the + // client checks too as defence in depth. + if (rel.includes('..') || rel.startsWith('/')) { + throw new Error(`Skill file path escapes install dir: ${rel}`); + } + await writeFileAt(stagingDir, rel, content, fileStates); + } + } + + // Atomic swap: rename existing tree aside, move staging in, rmtree the old. + const trashDir = `${targetDir}.mcpctl-trash-${String(process.pid)}`; + let hadExisting = false; + try { + await rename(targetDir, trashDir); + hadExisting = true; + } catch (err: unknown) { + if (!isNotFoundError(err)) throw err; + } + await rename(stagingDir, targetDir); + if (hadExisting) { + await rm(trashDir, { recursive: true, force: true }); + } + return fileStates; +} + +/** + * Symmetric atomic delete: rename to `.mcpctl-trash-` first, then + * rmtree. Skip if the directory doesn't exist. + */ +export async function removeSkillAtomic(targetDir: string): Promise { + const trashDir = `${targetDir}.mcpctl-trash-${String(process.pid)}`; + try { + await rename(targetDir, trashDir); + } catch (err: unknown) { + if (isNotFoundError(err)) return; + throw err; + } + await rm(trashDir, { recursive: true, force: true }); +} + +/** True if `path` is a directory. */ +export async function isDirectory(path: string): Promise { + try { + const s = await stat(path); + return s.isDirectory(); + } catch (err: unknown) { + if (isNotFoundError(err)) return false; + throw err; + } +} + +/** List subdirectories of `parent` that aren't staging/trash artifacts. */ +export async function listInstalledSkillNames(parent: string): Promise { + try { + const entries = await readdir(parent, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .filter((name) => !name.includes('.mcpctl-staging-') && !name.includes('.mcpctl-trash-')); + } catch (err: unknown) { + if (isNotFoundError(err)) return []; + throw err; + } +} + +async function writeFileAt( + base: string, + rel: string, + content: string, + states: Record, +): Promise { + const full = join(base, rel); + await mkdir(dirname(full), { recursive: true }); + await writeFile(full, content, 'utf-8'); + const buf = Buffer.from(content, 'utf-8'); + states[rel] = { sha256: sha256Of(buf), size: buf.length }; +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/src/utils/skills-state.ts b/src/cli/src/utils/skills-state.ts new file mode 100644 index 0000000..8f653cd --- /dev/null +++ b/src/cli/src/utils/skills-state.ts @@ -0,0 +1,136 @@ +/** + * Local state for `mcpctl skills sync`. Lives at + * `~/.mcpctl/skills-state.json` (NOT under `~/.claude/skills/` — Claude + * Code reads that tree and we don't want to pollute it with our + * bookkeeping). Tracks installed skills + per-file SHA-256 so the next + * sync can detect server-side changes (via top-level contentHash) and + * client-side modifications (via per-file hash drift). + */ +import { createHash } from 'node:crypto'; +import { readFile, writeFile, rename, mkdir, stat } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { homedir } from 'node:os'; + +const STATE_SCHEMA_VERSION = 1; + +export interface FileState { + /** sha256 of the file contents at write time. */ + sha256: string; + size: number; +} + +export interface SkillState { + id: string; + semver: string; + /** sha256 of the canonicalised skill body — matches mcpd's hash. */ + contentHash: string; + scope: 'project' | 'global' | 'agent'; + installDir: string; + files: Record; + /** sha256 of the postInstall script if any; null if none. */ + postInstallHash: string | null; + lastSyncedAt: string; +} + +export interface SkillsStateFile { + schemaVersion: number; + lastSync: string | null; + lastSyncProject: string | null; + /** keyed by skill name. */ + skills: Record; +} + +const DEFAULT_PATH = join(homedir(), '.mcpctl', 'skills-state.json'); + +export function defaultStatePath(): string { + return DEFAULT_PATH; +} + +export function emptyState(): SkillsStateFile { + return { + schemaVersion: STATE_SCHEMA_VERSION, + lastSync: null, + lastSyncProject: null, + skills: {}, + }; +} + +/** + * Compute sha256 of a buffer or string. Matches the + * `'sha256:'`-prefixed format mcpd produces. + */ +export function sha256Of(data: Buffer | string): string { + const buf = typeof data === 'string' ? Buffer.from(data, 'utf-8') : data; + return 'sha256:' + createHash('sha256').update(buf).digest('hex'); +} + +export async function loadState(path = DEFAULT_PATH): Promise { + try { + const raw = await readFile(path, 'utf-8'); + const parsed = JSON.parse(raw) as Partial; + // Be lenient: if the schema is older or fields missing, hydrate to defaults. + if (parsed.schemaVersion !== STATE_SCHEMA_VERSION) { + // For schemaVersion drift in v1 we treat the file as unparseable + // and start fresh; future migrations can dispatch on the value. + return emptyState(); + } + return { + schemaVersion: STATE_SCHEMA_VERSION, + lastSync: parsed.lastSync ?? null, + lastSyncProject: parsed.lastSyncProject ?? null, + skills: parsed.skills ?? {}, + }; + } catch (err: unknown) { + if (isNotFoundError(err)) { + return emptyState(); + } + throw err; + } +} + +/** Atomic write: temp file in the same dir, then rename. */ +export async function saveState(state: SkillsStateFile, path = DEFAULT_PATH): Promise { + const dir = dirname(path); + await mkdir(dir, { recursive: true }); + const tmp = `${path}.tmp.${String(process.pid)}`; + await writeFile(tmp, JSON.stringify(state, null, 2) + '\n', 'utf-8'); + await rename(tmp, path); +} + +/** Detect whether on-disk file content matches what we last wrote. */ +export async function hasFileBeenModified(installDir: string, relPath: string, recorded: FileState): Promise { + try { + const buf = await readFile(join(installDir, relPath)); + if (buf.length !== recorded.size) return true; + return sha256Of(buf) !== recorded.sha256; + } catch (err: unknown) { + if (isNotFoundError(err)) return true; // missing file ≠ pristine + throw err; + } +} + +/** Walk a skill's installed files and report which were edited locally. */ +export async function detectModifiedFiles(installDir: string, files: Record): Promise { + const modified: string[] = []; + for (const [rel, fs] of Object.entries(files)) { + if (await hasFileBeenModified(installDir, rel, fs)) { + modified.push(rel); + } + } + return modified; +} + +/** Check if a path exists. */ +export async function pathExists(p: string): Promise { + try { + await stat(p); + return true; + } catch (err: unknown) { + if (isNotFoundError(err)) return false; + throw err; + } +} + +function isNotFoundError(err: unknown): boolean { + return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; +} diff --git a/src/cli/tests/commands/claude.test.ts b/src/cli/tests/commands/claude.test.ts index cb17db3..9eb0053 100644 --- a/src/cli/tests/commands/claude.test.ts +++ b/src/cli/tests/commands/claude.test.ts @@ -37,9 +37,12 @@ describe('config claude', () => { { configDeps: { configDir: tmpDir }, log }, { client, credentialsDeps: { configDir: tmpDir }, log }, ); - await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath], { from: 'user' }); + // PR-5: --skip-skills bypasses the new sync + SessionStart hook side + // effects so this test stays focused on .mcp.json generation. The new + // sync flow has its own tests under src/cli/tests/utils/. + await cmd.parseAsync(['claude', '--project', 'homeautomation', '-o', outPath, '--skip-skills'], { from: 'user' }); - // No API call should be made + // No API call should be made when --skip-skills is set. expect(client.get).not.toHaveBeenCalled(); const written = JSON.parse(readFileSync(outPath, 'utf-8')); diff --git a/src/cli/tests/utils/project-marker.test.ts b/src/cli/tests/utils/project-marker.test.ts new file mode 100644 index 0000000..3df3181 --- /dev/null +++ b/src/cli/tests/utils/project-marker.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { findProjectMarker, writeProjectMarker, MARKER_FILENAME } from '../../src/utils/project-marker.js'; + +describe('project-marker', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-marker-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('finds marker in cwd', async () => { + await writeFile(join(tmp, MARKER_FILENAME), 'demo\n'); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result?.project).toBe('demo'); + expect(result?.markerPath).toBe(join(tmp, MARKER_FILENAME)); + }); + + it('walks up to find marker', async () => { + const sub = join(tmp, 'a', 'b', 'c'); + await mkdir(sub, { recursive: true }); + await writeFile(join(tmp, MARKER_FILENAME), 'parent-project'); + const result = await findProjectMarker(sub, '/never-exists'); + expect(result?.project).toBe('parent-project'); + }); + + it('returns null when no marker exists', async () => { + const sub = join(tmp, 'a', 'b'); + await mkdir(sub, { recursive: true }); + const result = await findProjectMarker(sub, '/never-exists'); + expect(result).toBeNull(); + }); + + it('stops at user home directory', async () => { + // Use tmp itself as the "home" — the walk should not go above it. + const sub = join(tmp, 'projects', 'demo'); + await mkdir(sub, { recursive: true }); + // Marker would be at /tmp's parent (above home) — should not be found. + const result = await findProjectMarker(sub, tmp); + expect(result).toBeNull(); + }); + + it('trims trailing whitespace from the project name', async () => { + await writeFile(join(tmp, MARKER_FILENAME), ' demo \nignored\n'); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result?.project).toBe('demo'); + }); + + it('rejects empty marker file', async () => { + await writeFile(join(tmp, MARKER_FILENAME), '\n'); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result).toBeNull(); + }); + + it('writeProjectMarker writes the file with a trailing newline', async () => { + const path = await writeProjectMarker(tmp, 'demo'); + expect(path).toBe(join(tmp, MARKER_FILENAME)); + const result = await findProjectMarker(tmp, '/never-exists'); + expect(result?.project).toBe('demo'); + }); +}); diff --git a/src/cli/tests/utils/sessionhook.test.ts b/src/cli/tests/utils/sessionhook.test.ts new file mode 100644 index 0000000..052e8b3 --- /dev/null +++ b/src/cli/tests/utils/sessionhook.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { installManagedSessionHook, removeManagedSessionHook, MARKER_KEY } from '../../src/utils/sessionhook.js'; + +describe('sessionhook', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-sessionhook-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('creates settings.json from scratch when missing', async () => { + const path = join(tmp, 'settings.json'); + const result = await installManagedSessionHook('mcpctl skills sync --quiet', path); + expect(result.updated).toBe(true); + const settings = JSON.parse(await readFile(path, 'utf-8')); + expect(settings.hooks.SessionStart).toHaveLength(1); + const entry = settings.hooks.SessionStart[0].hooks[0]; + expect(entry.command).toBe('mcpctl skills sync --quiet'); + expect(entry[MARKER_KEY]).toBe(true); + }); + + it('is idempotent — re-running does not add duplicates', async () => { + const path = join(tmp, 'settings.json'); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const second = await installManagedSessionHook('mcpctl skills sync --quiet', path); + expect(second.updated).toBe(false); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const entries = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); + const managed = entries.filter((e: Record) => e[MARKER_KEY] === true); + expect(managed).toHaveLength(1); + }); + + it('updates the command in place when it changes', async () => { + const path = join(tmp, 'settings.json'); + await installManagedSessionHook('mcpctl skills sync', path); + const updated = await installManagedSessionHook('mcpctl skills sync --quiet', path); + expect(updated.updated).toBe(true); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const managed = settings.hooks.SessionStart + .flatMap((g: { hooks: unknown[] }) => g.hooks) + .find((e: Record) => e[MARKER_KEY] === true); + expect(managed.command).toBe('mcpctl skills sync --quiet'); + }); + + it('preserves non-managed hooks', async () => { + const path = join(tmp, 'settings.json'); + await mkdir(tmp, { recursive: true }); + await writeFile(path, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user-hook' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); + expect(all).toHaveLength(2); + expect(all.find((e: Record) => e.command === 'echo user-hook')).toBeDefined(); + expect(all.find((e: Record) => e[MARKER_KEY] === true)).toBeDefined(); + }); + + it('remove drops the managed entry but keeps user hooks', async () => { + const path = join(tmp, 'settings.json'); + await writeFile(path, JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ type: 'command', command: 'echo user' }] }], + }, + })); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const removed = await removeManagedSessionHook(path); + expect(removed.removed).toBe(true); + const settings = JSON.parse(await readFile(path, 'utf-8')); + const all = settings.hooks.SessionStart.flatMap((g: { hooks: unknown[] }) => g.hooks); + expect(all).toHaveLength(1); + expect(all[0].command).toBe('echo user'); + }); + + it('remove is a no-op when no managed entry exists', async () => { + const path = join(tmp, 'settings.json'); + const result = await removeManagedSessionHook(path); + expect(result.removed).toBe(false); + }); + + it('survives empty settings.json', async () => { + const path = join(tmp, 'settings.json'); + await writeFile(path, ''); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const settings = JSON.parse(await readFile(path, 'utf-8')); + expect(settings.hooks.SessionStart).toHaveLength(1); + }); + + it('strips line comments before parsing', async () => { + const path = join(tmp, 'settings.json'); + await writeFile(path, '// a leading comment\n{\n "hooks": {}\n}\n'); + await installManagedSessionHook('mcpctl skills sync --quiet', path); + const settings = JSON.parse(await readFile(path, 'utf-8')); + expect(settings.hooks.SessionStart).toHaveLength(1); + }); +}); diff --git a/src/cli/tests/utils/skills-disk.test.ts b/src/cli/tests/utils/skills-disk.test.ts new file mode 100644 index 0000000..ed5ffeb --- /dev/null +++ b/src/cli/tests/utils/skills-disk.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, readFile, readdir, writeFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { installSkillAtomic, removeSkillAtomic, listInstalledSkillNames } from '../../src/utils/skills-disk.js'; + +describe('skills-disk', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-skills-disk-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + it('writes SKILL.md and aux files atomically', async () => { + const target = join(tmp, 'foo'); + const states = await installSkillAtomic(target, { + content: '# Foo skill', + files: { 'scripts/setup.sh': '#!/bin/sh\necho hi' }, + }); + expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('# Foo skill'); + expect(await readFile(join(target, 'scripts/setup.sh'), 'utf-8')).toBe('#!/bin/sh\necho hi'); + expect(states['SKILL.md']).toBeDefined(); + expect(states['SKILL.md'].sha256).toMatch(/^sha256:/); + expect(states['SKILL.md'].size).toBe('# Foo skill'.length); + expect(states['scripts/setup.sh']).toBeDefined(); + }); + + it('replaces an existing tree without leaving partial state', async () => { + const target = join(tmp, 'foo'); + await installSkillAtomic(target, { content: 'v1' }); + await installSkillAtomic(target, { + content: 'v2', + files: { 'extra.md': 'extra' }, + }); + expect(await readFile(join(target, 'SKILL.md'), 'utf-8')).toBe('v2'); + expect(await readFile(join(target, 'extra.md'), 'utf-8')).toBe('extra'); + // No staging or trash dirs left behind. + const entries = await readdir(tmp); + expect(entries.filter((e) => e.includes('mcpctl-staging') || e.includes('mcpctl-trash'))).toHaveLength(0); + }); + + it('rejects path-escape attempts', async () => { + const target = join(tmp, 'foo'); + await expect(installSkillAtomic(target, { + content: 'x', + files: { '../escaped': 'bad' }, + })).rejects.toThrow(/escapes install dir/); + }); + + it('rejects absolute paths in files{}', async () => { + const target = join(tmp, 'foo'); + await expect(installSkillAtomic(target, { + content: 'x', + files: { '/etc/passwd-like': 'bad' }, + })).rejects.toThrow(/escapes install dir/); + }); + + it('removes a skill atomically', async () => { + const target = join(tmp, 'foo'); + await installSkillAtomic(target, { content: 'x' }); + await removeSkillAtomic(target); + expect((await readdir(tmp)).filter((n) => n === 'foo')).toHaveLength(0); + }); + + it('remove is a no-op when target does not exist', async () => { + await expect(removeSkillAtomic(join(tmp, 'never-existed'))).resolves.toBeUndefined(); + }); + + it('listInstalledSkillNames ignores staging/trash artifacts', async () => { + const skillsDir = join(tmp, 'skills-root'); + await mkdir(skillsDir, { recursive: true }); + await mkdir(join(skillsDir, 'real-skill'), { recursive: true }); + await mkdir(join(skillsDir, 'real-skill.mcpctl-staging-1234'), { recursive: true }); + await mkdir(join(skillsDir, 'something.mcpctl-trash-9999'), { recursive: true }); + await writeFile(join(skillsDir, 'real-skill', 'SKILL.md'), 'x'); + + const names = await listInstalledSkillNames(skillsDir); + expect(names).toEqual(['real-skill']); + }); +}); diff --git a/src/cli/tests/utils/skills-state.test.ts b/src/cli/tests/utils/skills-state.test.ts new file mode 100644 index 0000000..77662c5 --- /dev/null +++ b/src/cli/tests/utils/skills-state.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtemp, rm, writeFile, readFile, mkdir } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + loadState, + saveState, + emptyState, + sha256Of, + hasFileBeenModified, + detectModifiedFiles, + type FileState, +} from '../../src/utils/skills-state.js'; + +describe('skills-state', () => { + let tmp: string; + + beforeEach(async () => { + tmp = await mkdtemp(join(tmpdir(), 'mcpctl-skills-state-')); + }); + + afterEach(async () => { + await rm(tmp, { recursive: true, force: true }); + }); + + describe('sha256Of', () => { + it('is deterministic and prefixed', () => { + expect(sha256Of('hello')).toMatch(/^sha256:[0-9a-f]{64}$/); + expect(sha256Of('hello')).toBe(sha256Of('hello')); + expect(sha256Of('hello')).not.toBe(sha256Of('hellp')); + }); + }); + + describe('load / save', () => { + it('returns empty state when file does not exist', async () => { + const state = await loadState(join(tmp, 'no-such.json')); + expect(state.skills).toEqual({}); + expect(state.lastSync).toBeNull(); + }); + + it('round-trips state', async () => { + const path = join(tmp, 'state.json'); + const state = emptyState(); + state.lastSync = '2026-05-07T00:00:00.000Z'; + state.lastSyncProject = 'demo'; + state.skills['my-skill'] = { + id: 'cuid-x', + semver: '0.1.0', + contentHash: sha256Of('body'), + scope: 'global', + installDir: '/tmp/foo', + files: { 'SKILL.md': { sha256: sha256Of('hi'), size: 2 } }, + postInstallHash: null, + lastSyncedAt: '2026-05-07T00:00:00.000Z', + }; + await saveState(state, path); + const loaded = await loadState(path); + expect(loaded).toEqual(state); + }); + + it('starts fresh on schema-version drift', async () => { + const path = join(tmp, 'state.json'); + await writeFile(path, JSON.stringify({ schemaVersion: 99, skills: { x: {} } })); + const state = await loadState(path); + expect(state.schemaVersion).toBe(1); + expect(state.skills).toEqual({}); + }); + }); + + describe('hasFileBeenModified', () => { + it('false when content matches recorded hash + size', async () => { + const dir = join(tmp, 'sk'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), 'hello'); + const recorded: FileState = { sha256: sha256Of('hello'), size: 5 }; + expect(await hasFileBeenModified(dir, 'SKILL.md', recorded)).toBe(false); + }); + + it('true when content differs', async () => { + const dir = join(tmp, 'sk'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), 'edited'); + const recorded: FileState = { sha256: sha256Of('hello'), size: 5 }; + expect(await hasFileBeenModified(dir, 'SKILL.md', recorded)).toBe(true); + }); + + it('true when file is missing', async () => { + const recorded: FileState = { sha256: sha256Of('hello'), size: 5 }; + expect(await hasFileBeenModified(tmp, 'missing.md', recorded)).toBe(true); + }); + }); + + describe('detectModifiedFiles', () => { + it('returns the list of edited paths', async () => { + const dir = join(tmp, 'sk'); + await mkdir(dir); + await writeFile(join(dir, 'SKILL.md'), 'pristine'); + await writeFile(join(dir, 'extra.md'), 'edited'); + const result = await detectModifiedFiles(dir, { + 'SKILL.md': { sha256: sha256Of('pristine'), size: 8 }, + 'extra.md': { sha256: sha256Of('original'), size: 8 }, + }); + expect(result).toEqual(['extra.md']); + }); + }); +}); diff --git a/src/mcpd/src/services/skill.service.ts b/src/mcpd/src/services/skill.service.ts index 13d81bb..285acbd 100644 --- a/src/mcpd/src/services/skill.service.ts +++ b/src/mcpd/src/services/skill.service.ts @@ -5,7 +5,7 @@ import type { IProjectRepository } from '../repositories/project.repository.js'; import type { IAgentRepository } from '../repositories/agent.repository.js'; import { CreateSkillSchema, UpdateSkillSchema } from '../validation/skill.schema.js'; import { NotFoundError } from './mcp-server.service.js'; -import type { ResourceRevisionService } from './resource-revision.service.js'; +import { ResourceRevisionService } from './resource-revision.service.js'; import type { ResourceProposalService } from './resource-proposal.service.js'; import { bumpSemver, type BumpKind } from '../utils/semver.js'; @@ -349,7 +349,7 @@ export class SkillService { name: string; description: string; semver: string; - contentHash: string | null; + contentHash: string; metadata: unknown; scope: 'project' | 'global' | 'agent'; }>> { @@ -359,7 +359,7 @@ export class SkillService { name: string; description: string; semver: string; - contentHash: string | null; + contentHash: string; metadata: unknown; scope: 'project' | 'global' | 'agent'; }> = []; @@ -367,16 +367,23 @@ export class SkillService { let scope: 'project' | 'global' | 'agent' = 'global'; if (s.projectId !== null) scope = 'project'; else if (s.agentId !== null) scope = 'agent'; + // Compute contentHash on the fly from the body shape that + // `mcpctl skills sync` will write to disk. The server hashes the + // canonicalised JSON; the client hashes the same JSON shape it + // receives, and they match. (Cheap — sha256 of a few KB.) + const contentHash = ResourceRevisionService.hash({ + content: s.content, + description: s.description, + priority: s.priority, + files: s.files, + metadata: s.metadata, + }); out.push({ id: s.id, name: s.name, description: s.description, semver: s.semver, - // contentHash lives on the latest revision row; sync clients can - // fetch it via /api/v1/revisions?resourceType=skill&resourceId=... - // until the resource row carries it directly. PR-5 will likely - // promote contentHash onto the resource itself. - contentHash: null, + contentHash, metadata: s.metadata, scope, }); -- 2.49.1 From e8c3803fac2bea5cf044f5891826ee1164bc34ae Mon Sep 17 00:00:00 2001 From: Michal Date: Thu, 7 May 2026 17:54:55 +0100 Subject: [PATCH 09/18] =?UTF-8?q?feat(web):=20bold=20redesign=20=E2=80=94?= =?UTF-8?q?=20Tailwind=20v4=20+=20shadcn-style=20primitives=20+=20Skills/P?= =?UTF-8?q?roposals/Revisions=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of the Skills + Revisions + Proposals work. The web UI gets a new design language and first-class affordances for everything the backend now supports. ## Visual direction - Tailwind v4 with custom @theme block (oklch tokens). Dark-mode-only (internal tool — light mode doubles QA surface). - Inter for UI, JetBrains Mono for code/IDs (loaded via Google Fonts; trivial to swap for self-hosted geist later — the fallback stack reads identically). - Sidebar layout (always-visible at desktop widths) replacing the previous top-bar nav. Pending-proposals badge polls every 30 s so reviewers see a queue building without refreshing. - Lucide icons throughout. - Spacing and radii on Tailwind defaults. Existing inline-styled pages (Projects, Agents, AgentDetail, ProjectPrompts, PersonalityDetail, Login) continue to work unchanged inside the new Layout — Tailwind doesn't conflict with their inline styles. A follow-up can migrate them incrementally. ## What's added ### Build infra (src/web/) - package.json: tailwindcss@^4 + @tailwindcss/vite, lucide-react, class-variance-authority, clsx, tailwind-merge, diff, geist (held for future self-hosting). - vite.config.ts: registers the @tailwindcss/vite plugin. - src/index.css: Tailwind import + @theme tokens + @layer base. - src/main.tsx: imports index.css. - src/lib/utils.ts: shadcn-style cn() helper. ### shadcn-style primitives (src/components/ui/) Hand-written rather than generated via `npx shadcn` so the repo doesn't depend on a CLI tool that needs interactive runtime: - button.tsx — variants: primary / secondary / ghost / danger / link; sizes: sm / md / lg / icon. - card.tsx — Card + Header/Title/Description/Content/Footer subparts. - badge.tsx — variants: default / info / success / warning / danger / outline. - input.tsx — Input + Textarea + Label. - tabs.tsx — no-dep accessible Tabs (no Radix needed for our use). - separator.tsx — h/v separator with role=separator. ### Diff component (src/components/Diff.tsx) Wraps the `diff` package (already added in PR-2) for inline unified- diff display with color-coded add/remove rows. Used by both the proposal review page and the skill revision-history tab. ### New pages (src/pages/) - Dashboard.tsx — at-a-glance home. Counts for skills, prompts, projects, agents, proposals; pending-proposals call-out card if any. - Skills.tsx — list view, separated into Global vs Project/Agent- scoped sections. - SkillDetail.tsx — name + semver + description; tabs for SKILL.md / Files / Metadata / History. History tab shows revisions with click-to-diff against the live body. - Proposals.tsx — queue with Pending/Approved/Rejected tabs. Pending count is highlighted in amber. - ProposalDetail.tsx — full body, diff against current resource (or "would create new" if it doesn't exist), approve button + reject- with-required-note flow. ### usePolling hook (src/hooks/) Tiny polling-with-cancellation hook used by Layout and Proposals. ### Layout rewrite (src/components/Layout.tsx) Sidebar with nav items: Dashboard, Projects, Agents, Skills, Proposals. Lucide icons. Active-route highlighting via NavLink. Pending-proposals warning badge on the Proposals item. ### Routes (src/App.tsx) New routes: /dashboard, /skills, /skills/:name, /proposals, /proposals/:id. Default redirects to /dashboard. ### API types (src/api.ts) Type defs for Skill, VisibleSkill, Proposal, Revision (with the shapes the new pages consume). ## Tests Existing 7 web tests still pass (Login + api). New page-level tests deferred — the new pages are mostly compositions of primitives and fetch hooks that round-trip to the backend; the backend tests already cover what they call. PR-7 polish can add render-and-click tests if coverage drift surfaces. ## Verification - `pnpm --filter @mcpctl/web build` clean, no warnings. - `pnpm test:run` whole monorepo: 162 test files / 2157 tests green. - Visual smoke deferred — needs a running mcpd to populate the fixtures. Manual smoke tested locally is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) --- pnpm-lock.yaml | 836 +++++++++++++++++++++++- src/web/package.json | 11 +- src/web/src/App.tsx | 15 +- src/web/src/api.ts | 66 ++ src/web/src/components/Diff.tsx | 53 ++ src/web/src/components/Layout.tsx | 159 +++-- src/web/src/components/ui/badge.tsx | 37 ++ src/web/src/components/ui/button.tsx | 48 ++ src/web/src/components/ui/card.tsx | 67 ++ src/web/src/components/ui/input.tsx | 45 ++ src/web/src/components/ui/separator.tsx | 22 + src/web/src/components/ui/tabs.tsx | 90 +++ src/web/src/hooks/usePolling.ts | 50 ++ src/web/src/index.css | 94 +++ src/web/src/lib/utils.ts | 11 + src/web/src/main.tsx | 1 + src/web/src/pages/Dashboard.tsx | 133 ++++ src/web/src/pages/ProposalDetail.tsx | 173 +++++ src/web/src/pages/Proposals.tsx | 136 ++++ src/web/src/pages/SkillDetail.tsx | 185 ++++++ src/web/src/pages/Skills.tsx | 119 ++++ src/web/vite.config.ts | 3 +- 22 files changed, 2274 insertions(+), 80 deletions(-) create mode 100644 src/web/src/components/Diff.tsx create mode 100644 src/web/src/components/ui/badge.tsx create mode 100644 src/web/src/components/ui/button.tsx create mode 100644 src/web/src/components/ui/card.tsx create mode 100644 src/web/src/components/ui/input.tsx create mode 100644 src/web/src/components/ui/separator.tsx create mode 100644 src/web/src/components/ui/tabs.tsx create mode 100644 src/web/src/hooks/usePolling.ts create mode 100644 src/web/src/index.css create mode 100644 src/web/src/lib/utils.ts create mode 100644 src/web/src/pages/Dashboard.tsx create mode 100644 src/web/src/pages/ProposalDetail.tsx create mode 100644 src/web/src/pages/Proposals.tsx create mode 100644 src/web/src/pages/SkillDetail.tsx create mode 100644 src/web/src/pages/Skills.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d8baa90..ded41a7 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)(jsdom@28.1.0)(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)(lightningcss@1.32.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)(jsdom@28.1.0)(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)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) src/cli: dependencies: @@ -195,6 +195,21 @@ importers: '@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) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + diff: + specifier: ^5.2.0 + version: 5.2.2 + geist: + specifier: ^1.5.1 + version: 1.7.0(next@16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) + lucide-react: + specifier: ^0.487.0 + version: 0.487.0(react@19.2.5) react: specifier: ^19.2.5 version: 19.2.5 @@ -204,13 +219,22 @@ importers: react-router-dom: specifier: ^7.7.0 version: 7.14.2(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + tailwind-merge: + specifier: ^3.3.1 + version: 3.5.0 devDependencies: + '@tailwindcss/vite': + specifier: ^4.1.16 + version: 4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) '@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/diff': + specifier: ^5.2.3 + version: 5.2.3 '@types/react': specifier: ^19.2.14 version: 19.2.14 @@ -219,13 +243,16 @@ importers: 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)) + version: 5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) jsdom: specifier: ^28.0.0 version: 28.1.0 + tailwindcss: + specifier: ^4.1.16 + version: 4.2.4 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) + version: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -387,6 +414,9 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + '@esbuild/aix-ppc64@0.27.3': resolution: {integrity: sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==} engines: {node: '>=18'} @@ -654,6 +684,143 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.34.5': + resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.34.5': + resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.2.4': + resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.2.4': + resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.2.4': + resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.2.4': + resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-ppc64@1.2.4': + resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} + cpu: [ppc64] + os: [linux] + + '@img/sharp-libvips-linux-riscv64@1.2.4': + resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} + cpu: [riscv64] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.2.4': + resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.2.4': + resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.34.5': + resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.34.5': + resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-ppc64@0.34.5': + resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ppc64] + os: [linux] + + '@img/sharp-linux-riscv64@0.34.5': + resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [riscv64] + os: [linux] + + '@img/sharp-linux-s390x@0.34.5': + resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.34.5': + resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.34.5': + resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.34.5': + resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.34.5': + resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.34.5': + resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.34.5': + resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.34.5': + resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@inkjs/ui@2.0.0': resolution: {integrity: sha512-5+8fJmwtF9UvikzLfph9sA+LS+l37Ij/szQltkuXLOAXwNkBX9innfzh4pLGXIB59vKEQUtc6D4qGvhD7h3pAg==} engines: {node: '>=18'} @@ -860,6 +1027,57 @@ packages: 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 + '@next/env@16.2.5': + resolution: {integrity: sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==} + + '@next/swc-darwin-arm64@16.2.5': + resolution: {integrity: sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@next/swc-darwin-x64@16.2.5': + resolution: {integrity: sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@next/swc-linux-arm64-gnu@16.2.5': + resolution: {integrity: sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-arm64-musl@16.2.5': + resolution: {integrity: sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + + '@next/swc-linux-x64-gnu@16.2.5': + resolution: {integrity: sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-linux-x64-musl@16.2.5': + resolution: {integrity: sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + + '@next/swc-win32-arm64-msvc@16.2.5': + resolution: {integrity: sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@next/swc-win32-x64-msvc@16.2.5': + resolution: {integrity: sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1054,6 +1272,99 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@swc/helpers@0.5.15': + resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==} + + '@tailwindcss/node@4.2.4': + resolution: {integrity: sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==} + + '@tailwindcss/oxide-android-arm64@4.2.4': + resolution: {integrity: sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + resolution: {integrity: sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.2.4': + resolution: {integrity: sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==} + engines: {node: '>= 20'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + resolution: {integrity: sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + resolution: {integrity: sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==} + engines: {node: '>= 20'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + resolution: {integrity: sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==} + engines: {node: '>= 20'} + cpu: [x64] + os: [linux] + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + resolution: {integrity: sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==} + engines: {node: '>= 20'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + resolution: {integrity: sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==} + engines: {node: '>= 20'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.2.4': + resolution: {integrity: sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==} + engines: {node: '>= 20'} + + '@tailwindcss/vite@4.2.4': + resolution: {integrity: sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==} + peerDependencies: + vite: ^5.2.0 || ^6 || ^7 || ^8 + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -1520,6 +1831,9 @@ packages: citty@0.2.1: resolution: {integrity: sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg==} + class-variance-authority@0.7.1: + resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + cli-boxes@3.0.0: resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} engines: {node: '>=10'} @@ -1540,10 +1854,17 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + client-only@0.0.1: + resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + code-excerpt@4.0.0: resolution: {integrity: sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1748,6 +2069,10 @@ packages: end-of-stream@1.4.5: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + enhanced-resolve@5.21.0: + resolution: {integrity: sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==} + engines: {node: '>=10.13.0'} + entities@8.0.0: resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} engines: {node: '>=20.19.0'} @@ -1993,6 +2318,11 @@ packages: engines: {node: '>=10'} deprecated: This package is no longer supported. + geist@1.7.0: + resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} + peerDependencies: + next: '>=13.2.0' + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -2042,6 +2372,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -2292,6 +2625,76 @@ packages: light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -2309,6 +2712,11 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lucide-react@0.487.0: + resolution: {integrity: sha512-aKqhOQ+YmFnwq8dWgGjOuLc8V1R9/c/yOd+zDY4+ohsR2Jo05lSGc3WsstYPIzcTpeosN7LoCkLReUUITvaIvw==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -2439,6 +2847,27 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + next@16.2.5: + resolution: {integrity: sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==} + engines: {node: '>=20.9.0'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@playwright/test': ^1.51.1 + babel-plugin-react-compiler: '*' + react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0 + sass: ^1.3.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + '@playwright/test': + optional: true + babel-plugin-react-compiler: + optional: true + sass: + optional: true + node-addon-api@5.1.0: resolution: {integrity: sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==} @@ -2584,6 +3013,10 @@ packages: pkg-types@2.3.0: resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==} + postcss@8.4.31: + resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} + engines: {node: ^10 || ^12 || >=14} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2811,6 +3244,10 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.34.5: + resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2934,6 +3371,19 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + styled-jsx@5.1.6: + resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} + engines: {node: '>= 12.0.0'} + peerDependencies: + '@babel/core': '*' + babel-plugin-macros: '*' + react: '>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0' + peerDependenciesMeta: + '@babel/core': + optional: true + babel-plugin-macros: + optional: true + supports-color@7.2.0: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} @@ -2945,6 +3395,16 @@ packages: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} + tailwind-merge@3.5.0: + resolution: {integrity: sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==} + + tailwindcss@4.2.4: + resolution: {integrity: sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==} + + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -3459,6 +3919,11 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + '@esbuild/aix-ppc64@0.27.3': optional: true @@ -3661,6 +4126,103 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} + '@img/colour@1.1.0': + optional: true + + '@img/sharp-darwin-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.2.4 + optional: true + + '@img/sharp-darwin-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.2.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.2.4': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.2.4': + optional: true + + '@img/sharp-libvips-linux-s390x@1.2.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.2.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.2.4': + optional: true + + '@img/sharp-linux-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.2.4 + optional: true + + '@img/sharp-linux-arm@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.2.4 + optional: true + + '@img/sharp-linux-ppc64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.2.4 + optional: true + + '@img/sharp-linux-riscv64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.2.4 + optional: true + + '@img/sharp-linux-s390x@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.2.4 + optional: true + + '@img/sharp-linux-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.34.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + optional: true + + '@img/sharp-wasm32@0.34.5': + dependencies: + '@emnapi/runtime': 1.10.0 + optional: true + + '@img/sharp-win32-arm64@0.34.5': + optional: true + + '@img/sharp-win32-ia32@0.34.5': + optional: true + + '@img/sharp-win32-x64@0.34.5': + optional: true + '@inkjs/ui@2.0.0(ink@6.8.0(@types/react@19.2.14)(react@19.2.4))': dependencies: chalk: 5.6.2 @@ -3902,6 +4464,32 @@ snapshots: react: 19.2.5 react-dom: 19.2.5(react@19.2.5) + '@next/env@16.2.5': {} + + '@next/swc-darwin-arm64@16.2.5': + optional: true + + '@next/swc-darwin-x64@16.2.5': + optional: true + + '@next/swc-linux-arm64-gnu@16.2.5': + optional: true + + '@next/swc-linux-arm64-musl@16.2.5': + optional: true + + '@next/swc-linux-x64-gnu@16.2.5': + optional: true + + '@next/swc-linux-x64-musl@16.2.5': + optional: true + + '@next/swc-win32-arm64-msvc@16.2.5': + optional: true + + '@next/swc-win32-x64-msvc@16.2.5': + optional: true + '@pinojs/redact@0.4.0': {} '@prisma/client@6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)': @@ -4041,6 +4629,78 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@swc/helpers@0.5.15': + dependencies: + tslib: 2.8.1 + + '@tailwindcss/node@4.2.4': + dependencies: + '@jridgewell/remapping': 2.3.5 + enhanced-resolve: 5.21.0 + jiti: 2.6.1 + lightningcss: 1.32.0 + magic-string: 0.30.21 + source-map-js: 1.2.1 + tailwindcss: 4.2.4 + + '@tailwindcss/oxide-android-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.2.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.2.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.2.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.2.4': + optional: true + + '@tailwindcss/oxide@4.2.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-arm64': 4.2.4 + '@tailwindcss/oxide-darwin-x64': 4.2.4 + '@tailwindcss/oxide-freebsd-x64': 4.2.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.2.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.2.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.2.4 + '@tailwindcss/oxide-linux-x64-musl': 4.2.4 + '@tailwindcss/oxide-wasm32-wasi': 4.2.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.2.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.2.4 + + '@tailwindcss/vite@4.2.4(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': + dependencies: + '@tailwindcss/node': 4.2.4 + '@tailwindcss/oxide': 4.2.4 + tailwindcss: 4.2.4 + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.29.0 @@ -4257,7 +4917,7 @@ snapshots: '@typescript-eslint/types': 8.56.0 eslint-visitor-keys: 5.0.1 - '@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))': + '@vitejs/plugin-react@5.2.0(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(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) @@ -4265,11 +4925,11 @@ snapshots: '@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) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(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))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -4281,7 +4941,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)(jsdom@28.1.0)(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)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/expect@4.0.18': dependencies: @@ -4292,13 +4952,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 - '@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))': + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.18 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) '@vitest/pretty-format@4.0.18': dependencies: @@ -4573,6 +5233,10 @@ snapshots: citty@0.2.1: {} + class-variance-authority@0.7.1: + dependencies: + clsx: 2.1.1 + cli-boxes@3.0.0: {} cli-cursor@4.0.0: @@ -4588,12 +5252,16 @@ snapshots: cli-width@4.1.0: {} + client-only@0.0.1: {} + cliui@8.0.1: dependencies: string-width: 4.2.3 strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + code-excerpt@4.0.0: dependencies: convert-to-spaces: 2.0.1 @@ -4766,6 +5434,11 @@ snapshots: dependencies: once: 1.4.0 + enhanced-resolve@5.21.0: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + entities@8.0.0: {} environment@1.1.0: {} @@ -5092,6 +5765,10 @@ snapshots: strip-ansi: 6.0.1 wide-align: 1.1.5 + geist@1.7.0(next@16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): + dependencies: + next: 16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -5159,6 +5836,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + has-flag@4.0.0: {} has-symbols@1.1.0: {} @@ -5414,6 +6093,55 @@ snapshots: process-warning: 4.0.1 set-cookie-parser: 2.7.2 + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -5428,6 +6156,10 @@ snapshots: dependencies: yallist: 3.1.1 + lucide-react@0.487.0(react@19.2.5): + dependencies: + react: 19.2.5 + lz-string@1.5.0: {} magic-string@0.30.21: @@ -5527,6 +6259,30 @@ snapshots: negotiator@1.0.0: {} + next@16.2.5(@babel/core@7.29.0)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@next/env': 16.2.5 + '@swc/helpers': 0.5.15 + baseline-browser-mapping: 2.10.23 + caniuse-lite: 1.0.30001791 + postcss: 8.4.31 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + styled-jsx: 5.1.6(@babel/core@7.29.0)(react@19.2.5) + optionalDependencies: + '@next/swc-darwin-arm64': 16.2.5 + '@next/swc-darwin-x64': 16.2.5 + '@next/swc-linux-arm64-gnu': 16.2.5 + '@next/swc-linux-arm64-musl': 16.2.5 + '@next/swc-linux-x64-gnu': 16.2.5 + '@next/swc-linux-x64-musl': 16.2.5 + '@next/swc-win32-arm64-msvc': 16.2.5 + '@next/swc-win32-x64-msvc': 16.2.5 + sharp: 0.34.5 + transitivePeerDependencies: + - '@babel/core' + - babel-plugin-macros + node-addon-api@5.1.0: {} node-fetch-native@1.6.7: {} @@ -5661,6 +6417,12 @@ snapshots: exsolve: 1.0.8 pathe: 2.0.3 + postcss@8.4.31: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -5912,6 +6674,38 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.34.5: + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.7.4 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.34.5 + '@img/sharp-darwin-x64': 0.34.5 + '@img/sharp-libvips-darwin-arm64': 1.2.4 + '@img/sharp-libvips-darwin-x64': 1.2.4 + '@img/sharp-libvips-linux-arm': 1.2.4 + '@img/sharp-libvips-linux-arm64': 1.2.4 + '@img/sharp-libvips-linux-ppc64': 1.2.4 + '@img/sharp-libvips-linux-riscv64': 1.2.4 + '@img/sharp-libvips-linux-s390x': 1.2.4 + '@img/sharp-libvips-linux-x64': 1.2.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.2.4 + '@img/sharp-libvips-linuxmusl-x64': 1.2.4 + '@img/sharp-linux-arm': 0.34.5 + '@img/sharp-linux-arm64': 0.34.5 + '@img/sharp-linux-ppc64': 0.34.5 + '@img/sharp-linux-riscv64': 0.34.5 + '@img/sharp-linux-s390x': 0.34.5 + '@img/sharp-linux-x64': 0.34.5 + '@img/sharp-linuxmusl-arm64': 0.34.5 + '@img/sharp-linuxmusl-x64': 0.34.5 + '@img/sharp-wasm32': 0.34.5 + '@img/sharp-win32-arm64': 0.34.5 + '@img/sharp-win32-ia32': 0.34.5 + '@img/sharp-win32-x64': 0.34.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -6051,6 +6845,13 @@ snapshots: dependencies: min-indent: 1.0.1 + styled-jsx@5.1.6(@babel/core@7.29.0)(react@19.2.5): + dependencies: + client-only: 0.0.1 + react: 19.2.5 + optionalDependencies: + '@babel/core': 7.29.0 + supports-color@7.2.0: dependencies: has-flag: 4.0.0 @@ -6059,6 +6860,12 @@ snapshots: tagged-tag@1.0.0: {} + tailwind-merge@3.5.0: {} + + tailwindcss@4.2.4: {} + + tapable@2.3.3: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -6213,7 +7020,7 @@ snapshots: vary@1.1.2: {} - vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2): + vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) @@ -6225,13 +7032,14 @@ snapshots: '@types/node': 25.3.0 fsevents: 2.3.3 jiti: 2.6.1 + lightningcss: 1.32.0 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@4.0.18(@types/node@25.3.0)(jiti@2.6.1)(jsdom@28.1.0)(lightningcss@1.32.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)) + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.18 '@vitest/runner': 4.0.18 '@vitest/snapshot': 4.0.18 @@ -6248,7 +7056,7 @@ snapshots: tinyexec: 1.0.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2) + vite: 7.3.1(@types/node@25.3.0)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.3.0 diff --git a/src/web/package.json b/src/web/package.json index ce525d4..5950949 100644 --- a/src/web/package.json +++ b/src/web/package.json @@ -12,17 +12,26 @@ }, "dependencies": { "@monaco-editor/react": "^4.7.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "diff": "^5.2.0", + "geist": "^1.5.1", + "lucide-react": "^0.487.0", "react": "^19.2.5", "react-dom": "^19.2.5", - "react-router-dom": "^7.7.0" + "react-router-dom": "^7.7.0", + "tailwind-merge": "^3.3.1" }, "devDependencies": { + "@tailwindcss/vite": "^4.1.16", "@testing-library/jest-dom": "^6.7.0", "@testing-library/react": "^16.3.0", + "@types/diff": "^5.2.3", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.0", "@vitejs/plugin-react": "^5.1.0", "jsdom": "^28.0.0", + "tailwindcss": "^4.1.16", "vite": "^7.2.0" } } diff --git a/src/web/src/App.tsx b/src/web/src/App.tsx index 7981fa7..e62c9a1 100644 --- a/src/web/src/App.tsx +++ b/src/web/src/App.tsx @@ -9,6 +9,11 @@ import { ProjectPromptsPage } from './pages/ProjectPrompts'; import { AgentsPage } from './pages/Agents'; import { AgentDetailPage } from './pages/AgentDetail'; import { PersonalityDetailPage } from './pages/PersonalityDetail'; +import { DashboardPage } from './pages/Dashboard'; +import { SkillsPage } from './pages/Skills'; +import { SkillDetailPage } from './pages/SkillDetail'; +import { ProposalsPage } from './pages/Proposals'; +import { ProposalDetailPage } from './pages/ProposalDetail'; export function App(): React.JSX.Element { const [tokenPresent, setTokenPresent] = useState(getToken() !== null); @@ -28,13 +33,19 @@ export function App(): React.JSX.Element { }> - } /> + } /> + } /> } /> } /> } /> } /> } /> - } /> + {/* PR-6: Skills + Proposals UI. */} + } /> + } /> + } /> + } /> + } /> diff --git a/src/web/src/api.ts b/src/web/src/api.ts index 9581938..7b89dee 100644 --- a/src/web/src/api.ts +++ b/src/web/src/api.ts @@ -95,6 +95,72 @@ export interface Personality { promptCount: number; } +// PR-3: Skill resource. Mirrors Prompt with the addition of multi-file +// bundles (`files`) and typed metadata (`hooks`, `mcpServers`, +// `postInstall`, …). +export interface Skill { + id: string; + name: string; + description: string; + content: string; + files: Record; + metadata: Record; + projectId: string | null; + agentId: string | null; + priority: number; + semver: string; + currentRevisionId: string | null; + createdAt: string; + updatedAt: string; + project?: { name: string } | null; + agent?: { name: string } | null; +} + +export interface VisibleSkill { + id: string; + name: string; + description: string; + semver: string; + contentHash: string; + metadata: unknown; + scope: 'global' | 'project' | 'agent'; +} + +// PR-2: ResourceProposal — generic propose/approve/reject queue. +// Replaces PromptRequest in the new path. +export interface Proposal { + id: string; + resourceType: 'prompt' | 'skill'; + name: string; + body: Record; + projectId: string | null; + agentId: string | null; + createdBySession: string | null; + createdByUserId: string | null; + status: 'pending' | 'approved' | 'rejected'; + reviewerNote: string; + approvedRevisionId: string | null; + createdAt: string; + updatedAt: string; + project?: { name: string } | null; + agent?: { name: string } | null; +} + +// PR-2: ResourceRevision — append-only audit log keyed by +// (resourceType, resourceId). +export interface Revision { + id: string; + resourceType: 'prompt' | 'skill'; + resourceId: string; + semver: string; + contentHash: string; + body: Record; + authorUserId: string | null; + authorSessionId: string | null; + note: string; + createdAt: string; +} + export interface PersonalityPrompt { promptId: string; promptName: string; diff --git a/src/web/src/components/Diff.tsx b/src/web/src/components/Diff.tsx new file mode 100644 index 0000000..9a44eab --- /dev/null +++ b/src/web/src/components/Diff.tsx @@ -0,0 +1,53 @@ +import * as React from 'react'; +import { diffLines } from 'diff'; +import { cn } from '../lib/utils'; + +/** + * Unified-diff renderer — line-by-line color-coded display. Powers the + * proposal review and revision-history pages. We use `diff.diffLines` + * (text-line granularity) rather than `diff.createPatch` because we + * want to render the diff as styled DOM, not as plain monospace text. + */ +export function Diff({ + before, + after, + className, +}: { + before: string; + after: string; + className?: string; +}): React.JSX.Element { + const parts = React.useMemo(() => diffLines(before, after), [before, after]); + + return ( +
+      {parts.map((part, i) => {
+        const color = part.added
+          ? 'text-(--color-success)'
+          : part.removed
+            ? 'text-(--color-danger)'
+            : 'text-(--color-fg-muted)';
+        const prefix = part.added ? '+ ' : part.removed ? '- ' : '  ';
+        const lines = part.value.split('\n');
+        // diffLines returns trailing newlines as separate lines; drop the
+        // empty tail so we don't render dead rows.
+        const trimmed = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
+        return (
+          
+            {trimmed.map((line, j) => (
+              
+                {prefix}
+                {line}
+              
+            ))}
+          
+        );
+      })}
+    
+ ); +} diff --git a/src/web/src/components/Layout.tsx b/src/web/src/components/Layout.tsx index b951f9e..3cb7737 100644 --- a/src/web/src/components/Layout.tsx +++ b/src/web/src/components/Layout.tsx @@ -1,80 +1,115 @@ import * as React from 'react'; import { NavLink, Outlet } from 'react-router-dom'; -import { clearToken } from '../api'; +import { LogOut, FolderKanban, Bot, Sparkles, Inbox, LayoutDashboard } from 'lucide-react'; + +import { api, clearToken, type Proposal } from '../api'; +import { Badge } from './ui/badge'; +import { cn } from '../lib/utils'; /** - * Top-of-page nav + outlet. Terminal-style dark theme so the UI feels - * adjacent to the CLI rather than a separate product. + * Sidebar layout. Pending-proposals badge polls every 30 s so reviewers + * see a queue building up without having to refresh the page. */ export function Layout(): React.JSX.Element { + const [pendingCount, setPendingCount] = React.useState(null); + + React.useEffect(() => { + let cancelled = false; + async function poll(): Promise { + try { + const proposals = await api.get('/api/v1/proposals?status=pending'); + if (!cancelled) setPendingCount(proposals.length); + } catch { + if (!cancelled) setPendingCount(null); + } + } + void poll(); + const id = setInterval(poll, 30_000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + 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', - }; +function NavItem({ + to, + icon: Icon, + children, + badge, +}: { + to: string; + icon: React.ComponentType<{ className?: string }>; + children: React.ReactNode; + badge?: number | null; +}): React.JSX.Element { + return ( + + cn( + 'flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm transition-colors', + isActive + ? 'bg-(--color-surface-hi) text-(--color-fg) font-medium' + : 'text-(--color-fg-muted) hover:bg-(--color-surface-hi) hover:text-(--color-fg)', + ) + } + > + + + {children} + + {typeof badge === 'number' && badge > 0 && ( + + {badge} + + )} + + ); } - -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/ui/badge.tsx b/src/web/src/components/ui/badge.tsx new file mode 100644 index 0000000..01904a5 --- /dev/null +++ b/src/web/src/components/ui/badge.tsx @@ -0,0 +1,37 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; + +const badgeVariants = cva( + 'inline-flex items-center rounded-md px-2 py-0.5 text-xs font-medium border', + { + variants: { + variant: { + default: + 'border-(--color-border) bg-(--color-surface) text-(--color-fg-muted)', + info: + 'border-(--color-primary)/30 bg-(--color-primary)/15 text-(--color-primary)', + success: + 'border-(--color-success)/30 bg-(--color-success-bg) text-(--color-success)', + warning: + 'border-(--color-warning)/30 bg-(--color-warning-bg) text-(--color-warning)', + danger: + 'border-(--color-danger)/30 bg-(--color-danger-bg) text-(--color-danger)', + outline: + 'border-(--color-border) text-(--color-fg)', + }, + }, + defaultVariants: { variant: 'default' }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +export const Badge = React.forwardRef( + ({ className, variant, ...props }, ref) => ( + + ), +); +Badge.displayName = 'Badge'; diff --git a/src/web/src/components/ui/button.tsx b/src/web/src/components/ui/button.tsx new file mode 100644 index 0000000..1f6ddbf --- /dev/null +++ b/src/web/src/components/ui/button.tsx @@ -0,0 +1,48 @@ +import * as React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '../../lib/utils'; + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium transition-colors disabled:pointer-events-none disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-primary) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-canvas) [&_svg]:size-4 [&_svg]:shrink-0', + { + variants: { + variant: { + primary: + 'bg-(--color-primary) text-(--color-primary-fg) hover:bg-(--color-primary-hover)', + secondary: + 'border border-(--color-border) bg-(--color-surface) text-(--color-fg) hover:bg-(--color-surface-hi)', + ghost: + 'text-(--color-fg) hover:bg-(--color-surface) hover:text-(--color-fg)', + danger: + 'bg-(--color-danger-bg) text-(--color-danger) border border-(--color-danger)/40 hover:bg-(--color-danger) hover:text-(--color-canvas)', + link: + 'text-(--color-primary) underline-offset-4 hover:underline', + }, + size: { + sm: 'h-8 px-3 text-sm', + md: 'h-9 px-4 text-sm', + lg: 'h-10 px-6 text-base', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'primary', + size: 'md', + }, + }, +); + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps {} + +export const Button = React.forwardRef( + ({ className, variant, size, ...props }, ref) => ( +