diff --git a/packages/codex-plugin/.codex-plugin/plugin.json b/packages/codex-plugin/.codex-plugin/plugin.json index 12491be..2c2e763 100644 --- a/packages/codex-plugin/.codex-plugin/plugin.json +++ b/packages/codex-plugin/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "judgmentkit", "version": "0.1.0", - "description": "AI UI review and handoff guidance that helps Codex preserve design intent, catch workflow mismatches, and enforce hard design-system acceptance gates.", + "description": "AI UI review, handoff guidance, and slide deck creation triggers that help Codex preserve design intent, catch workflow mismatches, and enforce hard design-system acceptance gates.", "author": { "name": "JudgmentKit", "url": "https://judgmentkit.ai" @@ -13,14 +13,18 @@ "design-handoff", "design-system", "figma", + "presentation", + "slide-deck", + "pptx", + "powerpoint", "workflow-fit", "ui-critique" ], "skills": "./skills/", "interface": { "displayName": "JudgmentKit", - "shortDescription": "AI UI review with hard design-system gates", - "longDescription": "JudgmentKit helps product designers and design teams review AI-generated UI before screens are planned, built, or accepted. It turns product briefs, Figma or Adobe handoffs, design-system guidance, and generated screens into clear decisions, states, terminology, workflow checks, implementation evidence, and hard design-system acceptance gates. If generated UI does not pass the active design system, it is a failed candidate, not an artifact. The hosted endpoint is for allowed or sanitized work; use a local or self-hosted JudgmentKit path for confidential briefs, unreleased designs, proprietary design-system details, source code, or customer data.", + "shortDescription": "AI UI review, slide decks, and hard design-system gates", + "longDescription": "JudgmentKit helps product designers and design teams review AI-generated UI before screens are planned, built, or accepted. It turns product briefs, Figma or Adobe handoffs, design-system guidance, and generated screens into clear decisions, states, terminology, workflow checks, implementation evidence, and hard design-system acceptance gates. It is also the discoverable route for JudgmentKit slide deck, presentation, PowerPoint, and PPTX requests from allowed briefs, review packets, handoffs, or implementation evidence once the MCP deck creation tool is available. If generated UI does not pass the active design system, it is a failed candidate, not an artifact. The hosted endpoint is for allowed or sanitized work; use a local or self-hosted JudgmentKit path for confidential briefs, unreleased designs, proprietary design-system details, source code, or customer data.", "developerName": "JudgmentKit", "category": "Productivity", "capabilities": [ @@ -32,7 +36,9 @@ "defaultPrompt": [ "Turn this product brief into UI decisions and review criteria.", "Review this sanitized Figma or generated screen for workflow fit and disclosure issues.", - "Create hard design-system acceptance criteria for this interface." + "Create hard design-system acceptance criteria for this interface.", + "Create a JudgmentKit slide deck from this product brief.", + "Turn this workflow review into a presentation handoff." ] }, "mcpServers": "./.mcp.json" diff --git a/packages/codex-plugin/README.md b/packages/codex-plugin/README.md index 83d8f81..dd69151 100644 --- a/packages/codex-plugin/README.md +++ b/packages/codex-plugin/README.md @@ -20,6 +20,18 @@ Current committed plugin version: `0.1.0`. - `skills/judgmentkit-hosted-mcp/agents/openai.yaml`: OpenAI agent metadata for the skill. - `CHANGELOG.md`: package-local release history. +## Discoverable Triggers + +Use the hosted skill when a user or agent asks JudgmentKit to review UI activity fit, set design-system acceptance gates, prepare handoff criteria, or create a slide deck, presentation, PowerPoint, or PPTX from an allowed brief, review packet, handoff, or implementation evidence. + +Visible trigger examples: + +- "Use JudgmentKit to create a slide deck from this product brief." +- "Make a JudgmentKit presentation from this workflow review." +- "Turn this handoff into a PPTX deck." + +If the active JudgmentKit MCP server does not expose a deck creation tool yet, report that the current endpoint cannot create the deck and collect the deck audience, purpose, source material, confidentiality boundary, and desired output format without fabricating an MCP result. + ## Hosted Privacy Boundary The included MCP configuration points to `https://judgmentkit.ai/mcp`. Hosted requests leave the local environment and are appropriate only for allowed or sanitized work. diff --git a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md index 007c90d..0c21c37 100644 --- a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md +++ b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/SKILL.md @@ -1,6 +1,6 @@ --- name: judgmentkit-hosted-mcp -description: Use JudgmentKit when the user explicitly asks or the project opts in to clarify what a UI must support, choose the right surface type, review workflow fit, set disclosure boundaries, enforce design-system acceptance gates, or prepare design-system handoff criteria before product UI is planned, generated, reviewed, or accepted. +description: Use JudgmentKit when the user explicitly asks or the project opts in to clarify what a UI must support, choose the right surface type, review workflow fit, set disclosure boundaries, enforce design-system acceptance gates, prepare design-system handoff criteria before product UI is planned, generated, reviewed, or accepted, or create a JudgmentKit slide deck, presentation, PowerPoint, or PPTX from allowed source material once the deck creation tool is available. --- # JudgmentKit Hosted MCP @@ -9,8 +9,26 @@ description: Use JudgmentKit when the user explicitly asks or the project opts i Use this skill to help product designers working with Codex clarify the job a product UI must do before screens, components, or styling are chosen. Start from a brief, Figma or Adobe handoff, design mockup, generated screen, or local source context and make the design decision set explicit: participants, outcomes, workflow, surface type, domain language, disclosure boundaries, required states, and evidence needed for handoff or acceptance. +Use this skill for JudgmentKit slide deck requests when the user asks to create, draft, generate, export, or turn source material into a deck, presentation, PowerPoint, or PPTX. Deck requests should start from the activity and audience, then use the deck creation MCP tool when the active endpoint exposes one. + MCP and server details belong only in setup, diagnostics, audit, or integration work. Keep them out of product-facing UI and copy. +## Slide Deck Creation + +Trigger this skill when the user asks JudgmentKit to create, draft, generate, export, or turn source material into a slide deck, presentation, PowerPoint, or PPTX. + +Before calling a deck creation MCP tool, establish: + +- audience and decision the deck should support +- source material and evidence authority +- desired deck form, such as narrative, handoff, review, pitch, or status update +- required output format and delivery path +- confidentiality boundary for hosted processing + +When the active JudgmentKit MCP server exposes a deck creation tool, call it with the reviewed activity context and allowed source material. Keep primary slide copy in domain language and do not expose prompts, schemas, resource ids, MCP server names, tool names, traces, or model configuration in slide content. + +If no deck creation tool is listed by the active endpoint, state that the current JudgmentKit endpoint cannot create the deck yet. Do not fabricate a JudgmentKit packet, deck, or MCP result. Continue only with a deterministic outline or requirements summary if the user wants that fallback. + ## Design-System Acceptance Gate Treat the active design system as an existential gate for generated UI: @@ -37,6 +55,7 @@ Before calling MCP tools, check the active user, project, and AGENTS instruction - Use JudgmentKit only when the user explicitly asks for it or the current project instructions opt into JudgmentKit for the task. - Do not send confidential briefs, unreleased designs, proprietary design-system details, source code, customer data, or internal roadmap context to the hosted endpoint unless workspace policy allows it. - When allowed, use JudgmentKit for UI generation, UI critique, implementation planning, workflow review, handoff review, and generated UI acceptance. +- When the user asks for a JudgmentKit deck, presentation, PowerPoint, or PPTX and the active endpoint exposes deck creation, use JudgmentKit for deck creation from allowed source material. - Do not use JudgmentKit for unrelated backend work, factual answers, docs-only edits, or general code review unless the task depends on UI activity fit. - If the activity is setup, debugging, auditing, or integration work, implementation details may be visible. Otherwise, keep prompts, schemas, resource ids, MCP servers, tool names, traces, and model configuration out of product UI. - When ranking findings, use `High`, `Medium`, and `Low` labels. @@ -59,6 +78,16 @@ Before calling MCP tools, check the active user, project, and AGENTS instruction Use the MCP response `structuredContent` for follow-up tool calls and implementation decisions. Use `content[0].text` as the concise human-facing planning card. +## Deck Workflow + +For slide deck requests: + +1. Confirm the deck audience, purpose, source material, confidentiality boundary, and target format from the brief or local context. +2. Use the activity contract to keep the deck focused on the participant decision and outcome, not implementation machinery. +3. Call the deck creation MCP tool when it is available from the active endpoint. +4. Treat returned deck guidance or artifacts as JudgmentKit output only when they came from the MCP tool. +5. Review slide copy for disclosure discipline before handoff: primary slides should use domain language, while prompts, schemas, resource ids, MCP details, traces, and model configuration stay out of the deck unless the deck is explicitly for setup, debugging, auditing, or integration. + ## Activity Contract Before suggesting screens, components, or styling, establish: diff --git a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/agents/openai.yaml b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/agents/openai.yaml index 5ff869f..777742f 100644 --- a/packages/codex-plugin/skills/judgmentkit-hosted-mcp/agents/openai.yaml +++ b/packages/codex-plugin/skills/judgmentkit-hosted-mcp/agents/openai.yaml @@ -1,7 +1,7 @@ interface: display_name: "JudgmentKit Hosted MCP" - short_description: "UI workflow review and hard design-system gates" - default_prompt: "Use $judgmentkit-hosted-mcp to review this UI brief for workflow fit, surface choice, disclosure boundaries, and the hard design-system acceptance gate. If the generated UI does not pass the active design system, treat it as a failed candidate and send it back through JudgmentKit for hard repair constraints." + short_description: "UI workflow review, slide deck creation, and hard design-system gates" + default_prompt: "Use $judgmentkit-hosted-mcp to review this UI brief for workflow fit, surface choice, disclosure boundaries, and the hard design-system acceptance gate, or to create a JudgmentKit slide deck, presentation, PowerPoint, or PPTX from allowed source material when the deck creation tool is available. If generated UI does not pass the active design system, treat it as a failed candidate and send it back through JudgmentKit for hard repair constraints. If the active JudgmentKit endpoint does not expose deck creation, say so and capture deck requirements without fabricating results." dependencies: tools: - type: "mcp" diff --git a/scripts/install-mcp.mjs b/scripts/install-mcp.mjs index 184abe4..a4b2775 100755 --- a/scripts/install-mcp.mjs +++ b/scripts/install-mcp.mjs @@ -30,6 +30,7 @@ export const JUDGMENTKIT_MCP_TOOL_NAMES = [ "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg", diff --git a/scripts/mcp-smoke.mjs b/scripts/mcp-smoke.mjs index bd1d92e..39cc011 100644 --- a/scripts/mcp-smoke.mjs +++ b/scripts/mcp-smoke.mjs @@ -51,11 +51,36 @@ try { "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg", ]); + const deckResponse = await withTimeout( + client.callTool({ + name: "create_slide_deck", + arguments: { + deck: { deck_id: "smoke deck" }, + slides: [ + { + template_id: "slide-21", + content: { + title: "Smoke deck", + subtitle: "MCP deck planning works.", + }, + }, + ], + dry_run: true, + }, + }), + 5_000, + ); + + assert.equal(deckResponse.isError, undefined); + assert.equal(deckResponse.structuredContent.deck_creation_status, "planned"); + assert.equal(deckResponse.structuredContent.slides[0].layout_id, "slide-21"); + const iconSearchResponse = await withTimeout( client.callTool({ name: "search_icon_catalog", @@ -99,6 +124,7 @@ try { { ok: true, tools: toolNames, + deck_status: deckResponse.structuredContent.deck_creation_status, icon_search_first_match: iconSearchResponse.structuredContent.icons[0].id, review_status: reviewResponse.structuredContent.review_status, }, diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index 8fc4a7f..37d8345 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -913,6 +913,7 @@ async function verifyPublicRoutes(baseUrl, options = {}) { "review_ui_implementation_candidate", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "operator-review-ui", ], "docs", diff --git a/site/build-site.mjs b/site/build-site.mjs index a67917b..c2565cb 100755 --- a/site/build-site.mjs +++ b/site/build-site.mjs @@ -6436,6 +6436,7 @@ examples/ai-native-design-system/canonical-examples.json

UI generation: the LLM or agent generates the interface outside JudgmentKit from the reviewed handoff.

Implementation contract: create_ui_implementation_contract supplies implementation_contract.design_system_source, implementation_contract.local_component_authority, implementation_contract.visual_token_adapter, implementation_contract.default_ai_native_design_system, approved primitives, required states, static checks, browser QA expectations, implementation_contract.visual_asset_policy, and implementation_contract.accessibility_policy before final handoff. review_ui_implementation_candidate checks generated UI against that contract and marks failed design-system candidates as repair-only diagnostics, not accepted artifacts.

Frontend adapter: create_frontend_generation_context combines a ready handoff, selected surface type, project frontend context, and verification expectations. create_frontend_implementation_skill_context turns that ready context into portable implementation instructions, semantic token roles, system font stacks, Lucide icon catalog policy, design-system provenance expectations, and local component authority without exposing raw skill files. Design-system compliance is not a substitute for activity fit.

+

Slide decks: create_slide_deck plans or exports JudgmentKit presentation-theme decks from user-facing slide content. Hosted callers can use dry-run planning; PPTX export requires a local artifact runtime.

Iteration: draft review produces updated context that re-enters source/activity review rather than becoming only a longer prompt.

Blocked path: if activity, workflow, or handoff is not ready, resolve targeted questions or leakage details before generating UI.

@@ -6468,6 +6469,10 @@ examples/ai-native-design-system/canonical-examples.json

Frontend Context

Call create_frontend_generation_context after the handoff gate when an agent needs frontend implementation guidance with selected surface type, project context, and verification expectations. Call create_frontend_implementation_skill_context when an MCP client needs compiled implementation skill guidance instead of repo-local skill access.

+
+

Slide Decks

+

Call create_slide_deck when an allowed brief, workflow review, handoff, or implementation evidence should become a JudgmentKit presentation, PowerPoint, or PPTX. The tool returns selected templates and content keys in dry-run mode, and writes PPTX artifacts only from a local @oai/artifact-tool runtime under the guarded output directory.

+

Guidance Profiles

Call recommend_ui_workflow_profiles when a brief sounds like specialized review work. Pass profile_id: "operator-review-ui" only when the recommendation evidence supports it.

diff --git a/src/mcp.mjs b/src/mcp.mjs index ccc6ab6..ad48386 100644 --- a/src/mcp.mjs +++ b/src/mcp.mjs @@ -1,3 +1,9 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { z } from "zod"; @@ -19,9 +25,23 @@ import { reviewUiWorkflowCandidate, searchIconCatalog, } from "./index.mjs"; +import { + JUDGMENTKIT_PRESENTATION_TEMPLATE_REGISTRY, + JUDGMENTKIT_PRESENTATION_THEME_ADAPTER_MANIFEST, + JUDGMENTKIT_SLIDE_SIZE, + composeJudgmentKitPresentationTemplate, + createJudgmentKitPresentation, + getJudgmentKitPresentationTemplate, + selectJudgmentKitPresentationTemplate, +} from "./presentation-theme/index.mjs"; const MCP_SERVER_NAME = "JudgmentKit"; const MCP_SERVER_VERSION = "0.6.5"; +const SLIDE_DECK_SCHEMA = "judgmentkit.mcp.slide-deck/v1"; +const MAX_SLIDE_DECK_SLIDES = 24; +const DEFAULT_SLIDE_DECK_OUTPUT_DIR = "outputs/judgmentkit-slide-decks"; +const PPTX_MIME_TYPE = + "application/vnd.openxmlformats-officedocument.presentationml.presentation"; const APPROVED_PRIMITIVES_INPUT_DESCRIPTION = "Optional allowed implementation primitives for generated UI evidence. These are implementation primitives only; do not list design-system component contract ids here. Defaults to the portable no-system primitive set."; const REVIEW_UI_IMPLEMENTATION_CANDIDATE_INPUT_DESCRIPTION = @@ -450,6 +470,116 @@ const FRONTEND_IMPLEMENTATION_SKILL_CONTEXT_TOOL = { }, }; +const CREATE_SLIDE_DECK_TOOL = { + name: "create_slide_deck", + description: + "Plan or create a JudgmentKit PPTX slide deck from user-facing slide content using the presentation-theme template registry. Without output.path or dry_run false, this returns a safe deck plan. Artifact creation requires a local @oai/artifact-tool runtime and writes only under outputs/judgmentkit-slide-decks.", + inputSchema: { + type: "object", + required: ["slides"], + properties: { + slides: { + type: "array", + minItems: 1, + maxItems: MAX_SLIDE_DECK_SLIDES, + description: + "Ordered slide specifications. Provide user-facing domain content only; keep prompts, schemas, tool traces, and implementation machinery out unless this is an explicit diagnostic deck.", + items: { + type: "object", + required: ["content"], + properties: { + template_id: { + type: "string", + description: + "Optional JudgmentKit presentation template id such as slide-21 or a supported legacy alias.", + }, + selection: { + type: "object", + description: + "Optional template selection criteria such as template_use, layout_family, surface_type, component, or nativeSurface. Used when template_id is omitted.", + }, + content: { + type: "object", + description: + "Template content values such as title, subtitle, body, bullets, rows, metrics, or media descriptions.", + }, + }, + additionalProperties: false, + }, + }, + deck: { + type: "object", + description: + "Optional deck metadata for planning and export.", + properties: { + deck_id: { + type: "string", + description: + "Stable id used for the default output filename. Unsafe characters are normalized.", + }, + title: { + type: "string", + description: "Optional human-readable deck title for metadata.", + }, + slide_size: { + type: "object", + description: + "Optional slide size with positive numeric width and height. Defaults to JudgmentKit widescreen.", + }, + theme_mode: { + type: "string", + enum: ["light", "dark"], + description: + "Optional JudgmentKit presentation theme mode. Defaults to light.", + }, + }, + additionalProperties: false, + }, + output: { + type: "object", + description: + "Optional artifact output request. Paths must be repo-relative .pptx files under outputs/judgmentkit-slide-decks.", + properties: { + path: { + type: "string", + description: + "Repo-relative .pptx path under outputs/judgmentkit-slide-decks. Supplying this requests artifact creation unless dry_run is true.", + }, + overwrite: { + type: "boolean", + description: "When true, replace an existing non-symlink output file.", + }, + }, + additionalProperties: false, + }, + runtime: { + type: "object", + description: + "Optional local runtime details for artifact creation. Hosted MCP callers should usually omit this and use dry_run planning.", + properties: { + artifact_tool_package: { + type: "string", + description: + "Optional local path to an @oai/artifact-tool package. If omitted, JudgmentKit checks standard Codex runtime environment paths.", + }, + }, + additionalProperties: false, + }, + dry_run: { + type: "boolean", + description: + "When true, return the selected deck plan without writing a PPTX. Defaults to true unless output.path is supplied or dry_run is explicitly false.", + }, + include_diagnostics: { + type: "boolean", + description: + "When true, include diagnostic template metadata and runtime resolution details. Default output redacts source paths and slide content.", + }, + }, + additionalProperties: false, + }, +}; + const LIST_ICON_CATALOG_TOOL = { name: "list_icon_catalog", description: @@ -940,7 +1070,617 @@ function iconCatalogSummary(iconCatalog) { .join(" "); } +function cloneJson(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function normalizeDeckId(value) { + const normalized = compactText(value) + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80); + + return normalized || "judgmentkit-slide-deck"; +} + +function normalizeThemeMode(value) { + return String(value ?? "light").toLowerCase() === "dark" ? "dark" : "light"; +} + +function normalizeSlideSize(value) { + const width = Number(value?.width ?? value?.w); + const height = Number(value?.height ?? value?.h); + + if (Number.isFinite(width) && width > 0 && Number.isFinite(height) && height > 0) { + return { width, height }; + } + + return cloneJson(JUDGMENTKIT_SLIDE_SIZE); +} + +function assertSlideDeckInput(args = {}) { + if (!Array.isArray(args.slides)) { + throw new JudgmentKitInputError("create_slide_deck requires slides."); + } + + if (args.slides.length < 1) { + throw new JudgmentKitInputError("create_slide_deck requires at least one slide."); + } + + if (args.slides.length > MAX_SLIDE_DECK_SLIDES) { + throw new JudgmentKitInputError( + `create_slide_deck supports at most ${MAX_SLIDE_DECK_SLIDES} slides per call.`, + { + code: "slide_deck_too_large", + details: { + max_slides: MAX_SLIDE_DECK_SLIDES, + supplied_slides: args.slides.length, + }, + }, + ); + } +} + +function assertRecordField(value, message) { + if (!isRecord(value)) { + throw new JudgmentKitInputError(message); + } +} + +function normalizeSlideDeckSlides(slides) { + return slides.map((slide, index) => { + assertRecordField(slide, `Slide ${index + 1} must be an object.`); + assertRecordField(slide.content, `Slide ${index + 1} requires content.`); + + return { + template_id: compactText(slide.template_id), + selection: isRecord(slide.selection) ? cloneJson(slide.selection) : undefined, + content: cloneJson(slide.content), + }; + }); +} + +function selectDeckTemplate(slide, index, includeDiagnostics = false) { + try { + if (slide.template_id) { + return { + selected_by: "template_id", + template: getJudgmentKitPresentationTemplate(slide.template_id, { + includeDiagnostics, + }), + }; + } + + if (isRecord(slide.selection)) { + const selected = selectJudgmentKitPresentationTemplate(slide.selection); + + if (!selected) { + throw new JudgmentKitInputError( + `Slide ${index + 1} selection did not match a JudgmentKit presentation template.`, + { + code: "slide_template_not_found", + details: { slide_number: index + 1 }, + }, + ); + } + + return { + selected_by: "selection", + template: includeDiagnostics + ? getJudgmentKitPresentationTemplate(selected.layout_id, { + includeDiagnostics: true, + }) + : selected, + }; + } + + const defaultTemplateId = index === 0 ? "slide-21" : "slide-01"; + + return { + selected_by: "default", + template: getJudgmentKitPresentationTemplate(defaultTemplateId, { + includeDiagnostics, + }), + }; + } catch (error) { + if (error instanceof JudgmentKitInputError) { + throw error; + } + + throw new JudgmentKitInputError( + `Slide ${index + 1} references an unknown JudgmentKit presentation template.`, + { + code: "slide_template_not_found", + details: { + slide_number: index + 1, + template_id: slide.template_id, + message: error instanceof Error ? error.message : String(error), + }, + }, + ); + } +} + +function slideTemplateSummary(slide, selected, index) { + const template = selected.template; + + return { + slide_number: index + 1, + layout_id: template.layout_id, + selected_by: selected.selected_by, + template_use: template.selection?.template_use, + layout_family: template.selection?.layout_family, + content_keys: Object.keys(slide.content).sort(), + }; +} + +function normalizeSlideDeckOutputPath(output, deckId) { + const rawPath = compactText(output?.path); + const relativePath = rawPath || `${DEFAULT_SLIDE_DECK_OUTPUT_DIR}/${deckId}.pptx`; + + if ( + path.isAbsolute(relativePath) || + relativePath.includes("\\") || + relativePath.includes("\0") + ) { + throw new JudgmentKitInputError( + "create_slide_deck output.path must be a repo-relative .pptx path using forward slashes.", + { code: "unsafe_output_path" }, + ); + } + + const normalized = path.posix.normalize(relativePath); + + if ( + normalized === "." || + normalized.startsWith("../") || + normalized === ".." || + normalized.startsWith("/") || + !normalized.startsWith(`${DEFAULT_SLIDE_DECK_OUTPUT_DIR}/`) || + path.posix.extname(normalized).toLowerCase() !== ".pptx" + ) { + throw new JudgmentKitInputError( + `create_slide_deck output.path must stay under ${DEFAULT_SLIDE_DECK_OUTPUT_DIR} and end in .pptx.`, + { + code: "unsafe_output_path", + details: { output_path: relativePath }, + }, + ); + } + + return normalized; +} + +async function prepareSlideDeckOutputTarget(relativePath, output = {}) { + const root = path.resolve(process.cwd(), DEFAULT_SLIDE_DECK_OUTPUT_DIR); + const absolutePath = path.resolve(process.cwd(), relativePath); + const parent = path.dirname(absolutePath); + + await fsp.mkdir(parent, { recursive: true }); + + const [realRoot, realParent] = await Promise.all([ + fsp.realpath(root), + fsp.realpath(parent), + ]); + const relativeParent = path.relative(realRoot, realParent); + + if ( + relativeParent.startsWith("..") || + path.isAbsolute(relativeParent) + ) { + throw new JudgmentKitInputError( + "create_slide_deck output.path resolves outside the allowed output directory.", + { + code: "unsafe_output_path", + details: { output_path: relativePath }, + }, + ); + } + + let existing; + try { + existing = await fsp.lstat(absolutePath); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + + if (existing?.isSymbolicLink()) { + throw new JudgmentKitInputError( + "create_slide_deck refuses to overwrite symlink output paths.", + { + code: "unsafe_output_path", + details: { output_path: relativePath }, + }, + ); + } + + if (existing && output.overwrite !== true) { + throw new JudgmentKitInputError( + "create_slide_deck output.path already exists. Set output.overwrite true to replace it.", + { + code: "output_exists", + details: { output_path: relativePath }, + }, + ); + } + + return { absolutePath, relativePath }; +} + +function sha256File(filePath) { + return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); +} + +function findZipEndOfCentralDirectory(buffer) { + const minOffset = Math.max(0, buffer.length - 65557); + + for (let offset = buffer.length - 22; offset >= minOffset; offset -= 1) { + if (buffer.readUInt32LE(offset) === 0x06054b50) { + return offset; + } + } + + return -1; +} + +function listZipEntries(buffer) { + const eocdOffset = findZipEndOfCentralDirectory(buffer); + + if (eocdOffset < 0) { + throw new JudgmentKitInputError( + "create_slide_deck exported file is not a valid PPTX ZIP.", + { code: "invalid_pptx_artifact" }, + ); + } + + const entryCount = buffer.readUInt16LE(eocdOffset + 10); + let offset = buffer.readUInt32LE(eocdOffset + 16); + const entries = []; + + for (let index = 0; index < entryCount; index += 1) { + if (buffer.readUInt32LE(offset) !== 0x02014b50) { + throw new JudgmentKitInputError( + "create_slide_deck exported PPTX has an invalid central directory.", + { code: "invalid_pptx_artifact" }, + ); + } + + const nameLength = buffer.readUInt16LE(offset + 28); + const extraLength = buffer.readUInt16LE(offset + 30); + const commentLength = buffer.readUInt16LE(offset + 32); + const name = buffer.subarray(offset + 46, offset + 46 + nameLength).toString("utf8"); + entries.push(name); + offset += 46 + nameLength + extraLength + commentLength; + } + + return entries; +} + +function inspectPptxArtifact(filePath) { + const buffer = fs.readFileSync(filePath); + const entries = listZipEntries(buffer); + const slideEntries = entries.filter((entry) => /^ppt\/slides\/slide\d+[.]xml$/.test(entry)); + + if ( + !entries.includes("[Content_Types].xml") || + !entries.includes("ppt/presentation.xml") || + slideEntries.length === 0 + ) { + throw new JudgmentKitInputError( + "create_slide_deck exported PPTX is missing required presentation parts.", + { code: "invalid_pptx_artifact" }, + ); + } + + return { + bytes: buffer.length, + entry_count: entries.length, + slide_count: slideEntries.length, + }; +} + +function artifactToolPackageCandidates(runtime = {}) { + const explicit = compactText(runtime.artifact_tool_package); + + if (explicit) { + return [explicit]; + } + + return [ + process.env.JUDGMENTKIT_ARTIFACT_TOOL_PACKAGE, + process.env.CODEX_RUNTIME_DEPENDENCIES + ? path.join( + process.env.CODEX_RUNTIME_DEPENDENCIES, + "node", + "node_modules", + "@oai", + "artifact-tool", + ) + : undefined, + process.env.CODEX_WORKSPACE_DEPENDENCIES + ? path.join( + process.env.CODEX_WORKSPACE_DEPENDENCIES, + "node", + "node_modules", + "@oai", + "artifact-tool", + ) + : undefined, + process.env.CODEX_DEPENDENCIES + ? path.join( + process.env.CODEX_DEPENDENCIES, + "node", + "node_modules", + "@oai", + "artifact-tool", + ) + : undefined, + path.join( + process.env.HOME ?? "", + ".cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/@oai/artifact-tool", + ), + ].filter(Boolean); +} + +async function loadArtifactToolRuntime(runtime = {}) { + const candidates = artifactToolPackageCandidates(runtime); + + for (const candidate of candidates) { + const packageJsonPath = path.join(candidate, "package.json"); + + if (!fs.existsSync(packageJsonPath)) { + continue; + } + + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); + + if (packageJson.name !== "@oai/artifact-tool") { + throw new JudgmentKitInputError( + "create_slide_deck artifact_tool_package is not @oai/artifact-tool.", + { + code: "invalid_artifact_runtime", + details: { artifact_tool_package: candidate }, + }, + ); + } + + if (!String(packageJson.version ?? "").startsWith("2.")) { + throw new JudgmentKitInputError( + `create_slide_deck requires @oai/artifact-tool 2.x; found ${packageJson.version}.`, + { + code: "unsupported_artifact_runtime", + details: { + artifact_tool_package: candidate, + version: packageJson.version, + }, + }, + ); + } + + const modulePath = path.join(candidate, "dist", "artifact_tool.mjs"); + const artifactTool = await import(pathToFileURL(modulePath).href); + const requiredExports = [ + "Presentation", + "PresentationFile", + "layers", + "shape", + "table", + "text", + ]; + const missingExports = requiredExports.filter( + (exportName) => artifactTool[exportName] === undefined, + ); + + if (missingExports.length > 0) { + throw new JudgmentKitInputError( + `create_slide_deck artifact runtime is missing exports: ${missingExports.join(", ")}.`, + { + code: "invalid_artifact_runtime", + details: { missing_exports: missingExports }, + }, + ); + } + + return { + package_path: candidate, + package_version: packageJson.version, + module: artifactTool, + }; + } + + throw new JudgmentKitInputError( + "create_slide_deck artifact creation requires a local @oai/artifact-tool runtime. Use dry_run true for hosted planning, or pass runtime.artifact_tool_package.", + { + code: "artifact_runtime_unavailable", + details: { + checked_candidates: candidates, + }, + }, + ); +} + +async function withoutArtifactExportConsoleNoise(callback) { + const originalLog = console.log; + const originalInfo = console.info; + + console.log = () => {}; + console.info = () => {}; + + try { + return await callback(); + } finally { + console.log = originalLog; + console.info = originalInfo; + } +} + +async function exportSlideDeckArtifact({ args, deckId, outputPath, selectedSlides, slideSize, themeMode }) { + const outputTarget = await prepareSlideDeckOutputTarget(outputPath, args.output ?? {}); + const runtime = await loadArtifactToolRuntime(args.runtime ?? {}); + const { + Presentation, + PresentationFile, + layers, + shape, + table, + text, + } = runtime.module; + const helpers = { layers, shape, table, text }; + const deck = createJudgmentKitPresentation({ + Presentation, + helpers, + slideSize, + themeMode, + }); + + for (const slide of selectedSlides) { + composeJudgmentKitPresentationTemplate(slide.layout_id, { + content: slide.content, + helpers, + kit: deck.kit, + presentation: deck.presentation, + }); + } + + const temporaryPath = `${outputTarget.absolutePath}.${process.pid}.${Date.now()}.tmp`; + const temporaryInspectPath = `${temporaryPath}.inspect.ndjson`; + + try { + await withoutArtifactExportConsoleNoise(async () => { + await (await PresentationFile.exportPptx(deck.presentation)).save(temporaryPath); + }); + const structure = inspectPptxArtifact(temporaryPath); + + if (structure.slide_count !== selectedSlides.length) { + throw new JudgmentKitInputError( + "create_slide_deck exported PPTX slide count does not match the request.", + { + code: "invalid_pptx_artifact", + details: { + expected_slide_count: selectedSlides.length, + actual_slide_count: structure.slide_count, + }, + }, + ); + } + + await fsp.rename(temporaryPath, outputTarget.absolutePath); + await fsp.rm(temporaryInspectPath, { force: true }).catch(() => {}); + + return { + artifact_ref: { + kind: "pptx", + path: outputTarget.relativePath, + sha256: sha256File(outputTarget.absolutePath), + bytes: structure.bytes, + mime_type: PPTX_MIME_TYPE, + }, + diagnostics: { + artifact_runtime: { + package: "@oai/artifact-tool", + version: runtime.package_version, + }, + pptx_structure: structure, + }, + }; + } catch (error) { + await Promise.all([ + fsp.rm(temporaryPath, { force: true }).catch(() => {}), + fsp.rm(temporaryInspectPath, { force: true }).catch(() => {}), + ]); + throw error; + } +} + +async function createSlideDeck(args = {}) { + assertSlideDeckInput(args); + + const deckInput = isRecord(args.deck) ? args.deck : {}; + const slides = normalizeSlideDeckSlides(args.slides); + const includeDiagnostics = args.include_diagnostics === true; + const deckId = normalizeDeckId(deckInput.deck_id ?? deckInput.title); + const slideSize = normalizeSlideSize(deckInput.slide_size ?? deckInput.slideSize); + const themeMode = normalizeThemeMode(deckInput.theme_mode ?? deckInput.themeMode); + const outputPath = normalizeSlideDeckOutputPath(args.output, deckId); + const shouldCreateArtifact = + args.dry_run === false || (isRecord(args.output) && compactText(args.output.path)); + const dryRun = args.dry_run === true || !shouldCreateArtifact; + const selectedSlides = slides.map((slide, index) => { + const selected = selectDeckTemplate(slide, index, includeDiagnostics); + + return { + ...slideTemplateSummary(slide, selected, index), + content: slide.content, + template: includeDiagnostics ? selected.template : undefined, + }; + }); + const warnings = dryRun + ? [ + "No PPTX artifact was written. Pass output.path or dry_run false from a local runtime to export a deck.", + ] + : []; + const result = { + schema: SLIDE_DECK_SCHEMA, + deck_creation_status: dryRun ? "planned" : "created", + deck: { + deck_id: deckId, + title: compactText(deckInput.title) || undefined, + slide_count: selectedSlides.length, + slide_size: slideSize, + theme_mode: themeMode, + template_registry_version: JUDGMENTKIT_PRESENTATION_TEMPLATE_REGISTRY.registry_version, + adapter_manifest_id: JUDGMENTKIT_PRESENTATION_THEME_ADAPTER_MANIFEST.id, + }, + planned_output: dryRun + ? { + kind: "pptx", + path: outputPath, + mime_type: PPTX_MIME_TYPE, + } + : undefined, + slides: selectedSlides.map(({ content, template, ...slide }) => { + if (includeDiagnostics) { + return { ...slide, template }; + } + + return slide; + }), + warnings, + }; + + if (dryRun) { + return result; + } + + const artifact = await exportSlideDeckArtifact({ + args, + deckId, + outputPath, + selectedSlides, + slideSize, + themeMode, + }); + + return { + ...result, + artifact_ref: artifact.artifact_ref, + diagnostics: includeDiagnostics ? artifact.diagnostics : undefined, + warnings: [], + }; +} + function planningStatus(result) { + if (result.deck_creation_status === "created") { + return "Deck created"; + } + + if (result.deck_creation_status === "planned") { + return "Deck plan ready"; + } + if (result.implementation_review_status === "passed") { return "Implementation gate passed"; } @@ -1629,6 +2369,52 @@ function formatIconCatalogCard(result) { return lines.join("\n"); } +function formatSlideDeckCard(result) { + const created = result.deck_creation_status === "created"; + const lines = [ + "## JudgmentKit Slide Deck", + `**Status:** ${planningStatus(result)}`, + created + ? "**Next step:** Review the exported PPTX artifact and run presentation evidence checks before treating it as release proof." + : "**Next step:** Review selected templates, then call create_slide_deck with output.path from a local artifact-tool runtime to export PPTX.", + ]; + + addSection(lines, "Deck", [ + firstLine("Deck id", result.deck?.deck_id), + firstLine("Title", result.deck?.title), + firstLine("Slides", String(result.deck?.slide_count ?? "")), + firstLine( + "Canvas", + result.deck?.slide_size + ? `${result.deck.slide_size.width} x ${result.deck.slide_size.height}` + : "", + ), + firstLine("Theme", result.deck?.theme_mode), + firstLine("Template registry", result.deck?.template_registry_version), + ]); + addSection(lines, "Artifact", [ + firstLine("Path", result.artifact_ref?.path ?? result.planned_output?.path), + firstLine("Kind", result.artifact_ref?.kind ?? result.planned_output?.kind), + firstLine("Bytes", result.artifact_ref?.bytes ? String(result.artifact_ref.bytes) : ""), + firstLine("SHA-256", result.artifact_ref?.sha256), + ]); + addSection(lines, "Slides", [ + listLine( + "Layouts", + (Array.isArray(result.slides) ? result.slides : []).map((slide) => + [ + `Slide ${slide.slide_number}`, + slide.layout_id, + slide.template_use, + ].filter(Boolean).join(": "), + ), + ), + ]); + addSection(lines, "Warnings", bulletList(result.warnings)); + + return lines.join("\n"); +} + function formatErrorCard(result) { const details = result.error?.details ?? {}; const lines = [ @@ -1658,6 +2444,10 @@ export function formatPlanningCard(result) { return formatErrorCard(result); } + if (result?.deck_creation_status) { + return formatSlideDeckCard(result); + } + if (result?.skill_context_status) { return formatFrontendSkillContextCard(result); } @@ -1727,6 +2517,7 @@ export function listTools() { UI_GENERATION_HANDOFF_TOOL, FRONTEND_GENERATION_CONTEXT_TOOL, FRONTEND_IMPLEMENTATION_SKILL_CONTEXT_TOOL, + CREATE_SLIDE_DECK_TOOL, LIST_ICON_CATALOG_TOOL, SEARCH_ICON_CATALOG_TOOL, GET_ICON_SVG_TOOL, @@ -1760,6 +2551,7 @@ export async function handleToolCall(name, args = {}) { UI_GENERATION_HANDOFF_TOOL.name, FRONTEND_GENERATION_CONTEXT_TOOL.name, FRONTEND_IMPLEMENTATION_SKILL_CONTEXT_TOOL.name, + CREATE_SLIDE_DECK_TOOL.name, LIST_ICON_CATALOG_TOOL.name, SEARCH_ICON_CATALOG_TOOL.name, GET_ICON_SVG_TOOL.name, @@ -1772,6 +2564,10 @@ export async function handleToolCall(name, args = {}) { } try { + if (name === CREATE_SLIDE_DECK_TOOL.name) { + return await createSlideDeck(args); + } + if (name === GET_ICON_SVG_TOOL.name) { return getIconSvg({ id: args.id }); } @@ -2113,6 +2909,48 @@ export function createJudgmentKitMcpServer() { ), ); + server.registerTool( + CREATE_SLIDE_DECK_TOOL.name, + { + description: CREATE_SLIDE_DECK_TOOL.description, + inputSchema: { + slides: z + .array( + z.object({ + template_id: z.string().optional(), + selection: z.record(z.any()).optional(), + content: z.record(z.any()), + }), + ) + .min(1) + .max(MAX_SLIDE_DECK_SLIDES), + deck: z + .object({ + deck_id: z.string().optional(), + title: z.string().optional(), + slide_size: z.record(z.any()).optional(), + theme_mode: z.enum(["light", "dark"]).optional(), + }) + .optional(), + output: z + .object({ + path: z.string().optional(), + overwrite: z.boolean().optional(), + }) + .optional(), + runtime: z + .object({ + artifact_tool_package: z.string().optional(), + }) + .optional(), + dry_run: z.boolean().optional(), + include_diagnostics: z.boolean().optional(), + }, + }, + async (args) => + createToolResult(await handleToolCall(CREATE_SLIDE_DECK_TOOL.name, args)), + ); + server.registerTool( LIST_ICON_CATALOG_TOOL.name, { diff --git a/tests/install-mcp.test.mjs b/tests/install-mcp.test.mjs index 04cd647..ebd46e6 100644 --- a/tests/install-mcp.test.mjs +++ b/tests/install-mcp.test.mjs @@ -28,6 +28,7 @@ const EXPECTED_TOOL_NAMES = [ "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg", diff --git a/tests/mcp-http.test.mjs b/tests/mcp-http.test.mjs index 01a4645..ddb82c9 100644 --- a/tests/mcp-http.test.mjs +++ b/tests/mcp-http.test.mjs @@ -24,6 +24,7 @@ const EXPECTED_TOOL_NAMES = [ "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg", @@ -134,6 +135,34 @@ async function runMcpClient(endpoint) { EXPECTED_TOOL_NAMES, ); + const deckResponse = await withTimeout( + client.callTool({ + name: "create_slide_deck", + arguments: { + deck: { deck_id: "http review deck" }, + slides: [ + { + template_id: "slide-21", + content: { + title: "Review deck", + subtitle: "Safe hosted planning response.", + }, + }, + ], + dry_run: true, + }, + }), + 5_000, + ); + + assert.equal(deckResponse.isError, undefined); + assert.ok(textContent(deckResponse).includes("## JudgmentKit Slide Deck")); + assert.ok(textContent(deckResponse).includes("**Status:** Deck plan ready")); + assert.equal(deckResponse.structuredContent.deck_creation_status, "planned"); + assert.equal(deckResponse.structuredContent.deck.deck_id, "http-review-deck"); + assert.equal(deckResponse.structuredContent.slides[0].layout_id, "slide-21"); + assert.equal("content" in deckResponse.structuredContent.slides[0], false); + const reviewResponse = await withTimeout( client.callTool({ name: "create_activity_model_review", @@ -444,6 +473,7 @@ assert.ok( ); for (const toolName of [ "create_activity_model_review", + "create_slide_deck", "create_ui_implementation_contract", "review_ui_implementation_candidate", ]) { diff --git a/tests/mcp-stdio.test.mjs b/tests/mcp-stdio.test.mjs index aba26ae..89420a5 100644 --- a/tests/mcp-stdio.test.mjs +++ b/tests/mcp-stdio.test.mjs @@ -136,6 +136,7 @@ try { "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg", @@ -159,6 +160,43 @@ try { reviewImplementationTool.inputSchema.properties.frontend_generation_context.type, "object", ); + const deckTool = toolsResponse.tools.find((tool) => tool.name === "create_slide_deck"); + + assert.ok(deckTool); + assert.equal(deckTool.inputSchema.properties.slides.type, "array"); + assert.equal(deckTool.inputSchema.properties.output.type, "object"); + + const deckResponse = await withTimeout( + client.callTool({ + name: "create_slide_deck", + arguments: { + deck: { deck_id: "stdio review deck" }, + slides: [ + { + template_id: "slide-21", + content: { + title: "Review deck", + subtitle: "Safe planning response.", + }, + }, + ], + dry_run: true, + }, + }), + 5_000, + ); + + assert.equal(deckResponse.isError, undefined); + assert.ok( + assertPlanningCard( + deckResponse, + "## JudgmentKit Slide Deck", + "Deck plan ready", + ).includes("slide-21"), + ); + assert.equal(deckResponse.structuredContent.deck_creation_status, "planned"); + assert.equal(deckResponse.structuredContent.deck.deck_id, "stdio-review-deck"); + assert.equal("content" in deckResponse.structuredContent.slides[0], false); const iconListResponse = await withTimeout( client.callTool({ diff --git a/tests/mcp.test.mjs b/tests/mcp.test.mjs index af50229..db1349f 100644 --- a/tests/mcp.test.mjs +++ b/tests/mcp.test.mjs @@ -1,4 +1,6 @@ import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; import { formatPlanningCard, @@ -33,6 +35,7 @@ assert.deepEqual( "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg", @@ -163,10 +166,135 @@ assert.equal(toolByName.create_ui_generation_handoff.inputSchema.required.includ assert.equal(toolByName.create_ui_generation_handoff.inputSchema.properties.cognitive_dimensions_review.type, "object"); assert.equal(toolByName.create_frontend_generation_context.inputSchema.required.includes("ui_generation_handoff"), true); assert.equal(toolByName.create_frontend_implementation_skill_context.inputSchema.required.includes("frontend_generation_context"), true); +assert.equal(toolByName.create_slide_deck.inputSchema.required.includes("slides"), true); +assert.equal(toolByName.create_slide_deck.inputSchema.properties.slides.maxItems, 24); +assert.equal(toolByName.create_slide_deck.inputSchema.properties.output.properties.path.type, "string"); +assert.equal(toolByName.create_slide_deck.inputSchema.properties.runtime.properties.artifact_tool_package.type, "string"); assert.equal(toolByName.list_icon_catalog.inputSchema.properties.include_svg.type, "boolean"); assert.equal(toolByName.search_icon_catalog.inputSchema.required.includes("query"), true); assert.equal(toolByName.get_icon_svg.inputSchema.required.includes("id"), true); +const deckPlan = await handleToolCall("create_slide_deck", { + deck: { + deck_id: "Quarterly review", + title: "Quarterly review", + }, + slides: [ + { + template_id: "slide-21", + content: { + title: "Quarterly review", + subtitle: "Evidence and decisions for the product team.", + }, + }, + { + selection: { + template_use: "chart", + layout_family: "chart-evidence", + }, + content: { + title: "Activation trend", + metrics: [ + { label: "Activation", value: "68%", detail: "Current quarter" }, + ], + }, + }, + ], + dry_run: true, +}); +assert.equal("error" in deckPlan, false); +assert.equal(deckPlan.schema, "judgmentkit.mcp.slide-deck/v1"); +assert.equal(deckPlan.deck_creation_status, "planned"); +assert.equal(deckPlan.deck.deck_id, "quarterly-review"); +assert.equal(deckPlan.deck.slide_count, 2); +assert.equal(deckPlan.deck.template_registry_version, "0.1.0"); +assert.equal(deckPlan.planned_output.path, "outputs/judgmentkit-slide-decks/quarterly-review.pptx"); +assert.equal(deckPlan.slides[0].layout_id, "slide-21"); +assert.equal(deckPlan.slides[0].selected_by, "template_id"); +assert.equal(deckPlan.slides[0].content_keys.includes("title"), true); +assert.equal("content" in deckPlan.slides[0], false); +assert.equal(deckPlan.slides[1].layout_id, "slide-64"); +assert.equal(deckPlan.slides[1].selected_by, "selection"); + +const deckPlanText = formatPlanningCard(deckPlan); +assert.ok(deckPlanText.includes("## JudgmentKit Slide Deck")); +assert.ok(deckPlanText.includes("**Status:** Deck plan ready")); +assert.ok(deckPlanText.includes("slide-21")); +assert.equal(deckPlanText.includes("Evidence and decisions for the product team."), false); + +const missingSlidesDeck = await handleToolCall("create_slide_deck", { + slides: [], + dry_run: true, +}); +assert.equal("error" in missingSlidesDeck, true); +assert.match(missingSlidesDeck.error.message, /at least one slide/i); + +const unknownTemplateDeck = await handleToolCall("create_slide_deck", { + slides: [ + { + template_id: "not-a-template", + content: { title: "Unknown" }, + }, + ], + dry_run: true, +}); +assert.equal("error" in unknownTemplateDeck, true); +assert.equal(unknownTemplateDeck.error.code, "slide_template_not_found"); + +const unsafeOutputDeck = await handleToolCall("create_slide_deck", { + output: { path: "../unsafe.pptx" }, + slides: [ + { + content: { title: "Unsafe path" }, + }, + ], +}); +assert.equal("error" in unsafeOutputDeck, true); +assert.equal(unsafeOutputDeck.error.code, "unsafe_output_path"); + +const missingRuntimeDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + }, + slides: [ + { + content: { title: "Needs runtime" }, + }, + ], +}); +assert.equal("error" in missingRuntimeDeck, true); +assert.equal(missingRuntimeDeck.error.code, "artifact_runtime_unavailable"); + +const existingOutputPath = path.join( + process.cwd(), + "outputs", + "judgmentkit-slide-decks", + "existing-output-test.pptx", +); +fs.mkdirSync(path.dirname(existingOutputPath), { recursive: true }); +fs.writeFileSync(existingOutputPath, "existing"); +try { + const existingOutputDeck = await handleToolCall("create_slide_deck", { + dry_run: false, + output: { + path: "outputs/judgmentkit-slide-decks/existing-output-test.pptx", + }, + runtime: { + artifact_tool_package: "/no/such/artifact-tool", + }, + slides: [ + { + content: { title: "Existing output" }, + }, + ], + }); + assert.equal("error" in existingOutputDeck, true); + assert.equal(existingOutputDeck.error.code, "output_exists"); +} finally { + fs.rmSync(existingOutputPath, { force: true }); +} + const iconList = await handleToolCall("list_icon_catalog", { limit: 2 }); assert.equal("error" in iconList, false); assert.equal(iconList.icons.length, 2); diff --git a/tests/site.test.mjs b/tests/site.test.mjs index 49272bc..05612ed 100644 --- a/tests/site.test.mjs +++ b/tests/site.test.mjs @@ -28,6 +28,7 @@ const EXPECTED_TOOL_NAMES = [ "create_ui_generation_handoff", "create_frontend_generation_context", "create_frontend_implementation_skill_context", + "create_slide_deck", "list_icon_catalog", "search_icon_catalog", "get_icon_svg",