From 641849e8dba066c2c405cc444aa610a24e94e3f7 Mon Sep 17 00:00:00 2001 From: anandpant <109482096+anandpant@users.noreply.github.com> Date: Sun, 6 Sep 2026 00:32:12 +0000 Subject: [PATCH] feat(cli): add Universal Canvas command (#321) ## Summary - add a noninteractive `sketchi canvas` command for typed CanvasSpec file, stdin, or inline JSON input - validate the public create-canvas request/response with shared repository schemas, preserve the returned diagram locally, and export PNG, Excalidraw, or scene consistently with `generate` - preserve submitted diagram identity, distinguish invalid-canvas rejections from server failures, and report CanvasSpec ID validation at `spec.diagramId` - document the production endpoint, preview/testing override, machine-readable output, and supported canvas workflows - cover success, malformed input/response, identity drift, typed rejection, server and network failures, storage, help, and built-CLI file/stdin paths - register the CLI HTTP Promise boundary in the project-graph guard and add a minor changeset for `@sketchi/cli` ## Proof - `pnpm run test:tools` (5 files, 93 tests) - `pnpm nx test sketchi-cli` (21 files, 198 tests) - `pnpm nx typecheck sketchi-cli` - `pnpm nx build sketchi-cli` - `pnpm nx run-many -t typecheck,test,build` - `pnpm nx build-storybook diagram-ui` The full required proof predates the review remediation; `test:tools` and the affected CLI typecheck, test, and build were rerun after remediation. Live production proof was intentionally not run before merge. --- .changeset/fuzzy-canvases-create.md | 5 + apps/cli/README.md | 27 +- apps/cli/scripts/build.mjs | 2 +- apps/cli/src/__fixtures__/help/agent-docs.txt | 24 +- apps/cli/src/__fixtures__/help/canvas.txt | 42 ++ apps/cli/src/__fixtures__/help/generate.txt | 2 +- apps/cli/src/__fixtures__/help/root.txt | 1 + apps/cli/src/audit.test.ts | 19 +- apps/cli/src/canvas-cli.test.ts | 220 ++++++++++ apps/cli/src/canvas.test.ts | 400 ++++++++++++++++++ apps/cli/src/canvas.ts | 283 +++++++++++++ apps/cli/src/cli.ts | 158 ++++++- apps/cli/src/contracts.ts | 12 +- apps/cli/src/document.ts | 29 ++ apps/cli/src/errors.ts | 26 ++ apps/cli/src/generate-workflow.test.ts | 8 +- apps/cli/src/generate-workflow.ts | 93 ++-- apps/cli/src/help-brand.ts | 11 +- apps/cli/src/help.test.ts | 4 + apps/cli/src/input.ts | 6 +- apps/cli/src/internal/effect-unstable-cli.ts | 1 + apps/cli/src/output.ts | 7 + apps/cli/src/storage.test.ts | 56 ++- apps/cli/src/storage.ts | 23 +- docs/canvas-spec.md | 14 + tools/project-graph.test.ts | 1 + 26 files changed, 1380 insertions(+), 94 deletions(-) create mode 100644 .changeset/fuzzy-canvases-create.md create mode 100644 apps/cli/src/__fixtures__/help/canvas.txt create mode 100644 apps/cli/src/canvas-cli.test.ts create mode 100644 apps/cli/src/canvas.test.ts create mode 100644 apps/cli/src/canvas.ts diff --git a/.changeset/fuzzy-canvases-create.md b/.changeset/fuzzy-canvases-create.md new file mode 100644 index 00000000..67b93460 --- /dev/null +++ b/.changeset/fuzzy-canvases-create.md @@ -0,0 +1,5 @@ +--- +"@sketchi/cli": minor +--- + +Add the noninteractive `sketchi canvas` command for typed Universal CanvasSpec creation, local persistence, and artifact export through the public production API. diff --git a/apps/cli/README.md b/apps/cli/README.md index 3290d952..54c9e5f7 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -17,7 +17,7 @@ Sketchi stores canonical diagram documents and authoritative Excalidraw artifacts under `~/.sketchi/diagrams`. Its manual and recovery workflows stay local; -generation and encrypted snapshot exchange are explicit, credential-free network +generation, Universal Canvas compilation, and encrypted snapshot exchange are explicit, credential-free network commands. ## Install @@ -83,7 +83,27 @@ sketchi generate --prompt "Map release approval" --output json `--format` accepts `png`, `excalidraw`, or `scene`; their default names are `.png`, `.excalidraw`, and `.scene.json`. With `--dest -`, stdout contains artifact bytes only and the text or JSON status envelope moves to -stderr. Generation, sharing, and pulling are the CLI's three network commands. +stderr. Generation, Universal Canvas compilation, sharing, and pulling are the CLI's four network commands. + +## Build a Universal CanvasSpec + +`canvas` sends an already-authored, typed CanvasSpec to Sketchi's public +create-canvas API, validates the response, preserves the normalized scene and +editable Excalidraw artifact in the local store, and exports PNG by default: + +```sh +sketchi canvas --file canvas.json --output json +printf '%s' "$CANVAS_SPEC" | sketchi canvas --file - --format excalidraw --dest canvas.excalidraw +``` + +The input is the CanvasSpec object itself—not a request wrapper and not raw +Excalidraw JSON. The command never prompts, so files and piped stdin are safe for +agents and scripts. `--format` and `--dest` have the same behavior as `generate`. +The endpoint defaults to +`https://playground.sketchi.app/api/v1/canvases/create`; use `--endpoint URL` or +`SKETCHI_CANVAS_ENDPOINT` only for preview and local testing. The existing +`create` command remains the separate, strictly offline command for accepted +flowchart, mindmap, and sequence documents. ## Create and work with a diagram offline @@ -259,7 +279,7 @@ to human text TTYs and is never enabled by JSON output, pipes, redirects, or CI. After exporting PNG to a file, agents can follow the returned hint to display that path as an inline Markdown image for the user. -`generate`, `share`, and `pull` are deliberately separate, explicit network +`generate`, `canvas`, `share`, and `pull` are deliberately separate, explicit network commands. They remain credential-free and each makes one HTTPS request. Share is randomized by its key and IV; pull depends on remote availability and untrusted input, so neither belongs to determinism claims. @@ -279,6 +299,7 @@ sketchi docs sketchi create --help sketchi patch --help sketchi generate --help +sketchi canvas --help sketchi share --help sketchi pull --help sketchi restore --help diff --git a/apps/cli/scripts/build.mjs b/apps/cli/scripts/build.mjs index 4f71a64b..2e23b3fe 100644 --- a/apps/cli/scripts/build.mjs +++ b/apps/cli/scripts/build.mjs @@ -137,7 +137,7 @@ const packageManifest = { name: "sketchi", version, description: - "Local Sketchi authoring CLI with six offline authoring and recovery commands plus three credential-free network commands for generation and encrypted Excalidraw sharing.", + "Local Sketchi authoring CLI with offline workflows and four credential-free network commands for generation, Universal Canvas compilation, and encrypted Excalidraw sharing.", keywords: ["sketchi", "diagram", "flowchart", "mindmap", "excalidraw", "cli"], homepage: "https://sketchi.app", repository: { diff --git a/apps/cli/src/__fixtures__/help/agent-docs.txt b/apps/cli/src/__fixtures__/help/agent-docs.txt index 7f9ae60b..03ab2a3e 100644 --- a/apps/cli/src/__fixtures__/help/agent-docs.txt +++ b/apps/cli/src/__fixtures__/help/agent-docs.txt @@ -1,8 +1,10 @@ Sketchi CLI contracts for agents and automation. Command map: - Start with generate for prompt-to-PNG. Use show, edit, patch, list, restore, and export for - local records. Use create for accepted canonical JSON. Share and pull are explicit network + Start with generate for prompt-to-PNG or canvas for an authored CanvasSpec. Use show, list, + and export for every local record. Edit and patch support canonical flowchart, mindmap, and + sequence records. Use create for accepted canonical JSON. + Share and pull are explicit network boundaries for Excalidraw links. Run sketchi COMMAND --help for every flag and example. Manual JSON workflow (strictly offline): @@ -19,11 +21,15 @@ Canonical mindmap example: Canonical sequence example: {"type":"sequence","spec":{"id":"checkout-sequence","title":"Checkout sequence","participants":[{"id":"browser","label":"Browser"},{"id":"api","label":"API"}],"messages":[{"id":"request","source":"browser","target":"api","label":"Submit checkout"}]}} +Universal CanvasSpec example: + {"kind":"canvas","version":1,"diagramId":"release-board","title":"Release board","width":640,"height":360,"accentColor":"#2563eb","backgroundColor":"#ffffff","elements":[{"type":"node","id":"ready","nodeId":"ready","shape":"rectangle","x":40,"y":40,"width":240,"height":120,"label":"Ready to release"}],"layers":[],"layouts":[],"zOrder":["ready"]} + Semantic color patch example: sketchi patch release-flow --json '{"operations":[{"op":"setStyle","selector":{"nodeIds":["review","approve"]},"style":{"fillColor":"#dbeafe","strokeColor":"#2563eb","textColor":"#1e3a8a"}}]}' Explicit network commands (one credential-free HTTPS request each): sketchi generate [--prompt TEXT] [--type flowchart|mindmap|sequence|er|architecture|swimlane|state-machine] [--model MODEL] + sketchi canvas --file PATH|- [--format png|excalidraw|scene] [--dest PATH|-] sketchi share DIAGRAM_ID [--open] sketchi pull DIAGRAM_ID --link URL|- generate makes one unauthenticated HTTPS POST to the public Sketchi generate API at https://playground.sketchi.app/api/v1/generate @@ -32,9 +38,12 @@ Explicit network commands (one credential-free HTTPS request each): through the same local store as create. Without --type, the model selects a supported native type; the default model is gemini-3.1-flash-lite. Override the endpoint for preview or local testing with SKETCHI_GENERATE_ENDPOINT or --endpoint URL. + canvas submits the CanvasSpec object itself to https://playground.sketchi.app/api/v1/canvases/create, validates the typed + response, stores the normalized scene and Excalidraw artifact, and exports locally. Override its + endpoint with SKETCHI_CANVAS_ENDPOINT or --endpoint URL. Input and output contracts: - create/edit/patch require exactly one of --file PATH|- or --json VALUE. --file - reads one + create/edit/patch/canvas require exactly one of --file PATH|- or --json VALUE. --file - reads one noninteractive UTF-8 JSON document from stdin and exits with usage code 2 on a TTY. --json is inline input only. --prompt is always direct and noninteractive. With no --prompt, generate opens a short prompt/type/PNG-destination wizard only when stdin and stdout are human @@ -66,7 +75,7 @@ Offline boundary and storage: A pulled record is detached: diagram.excalidraw is authoritative while document.json and scene.json remain non-authoritative provenance. A patched record makes scene.json authoritative while retaining document.json as provenance. show/list expose these states; edit refuses both. - Formats are scene, excalidraw, and png. generate persists the canonical record before exporting + Formats are scene, excalidraw, and png. generate and canvas persist the local record before exporting the requested artifact; a later destination failure leaves that record recoverable. If no PNG is stored, export deterministically renders one on demand from the local scene and Excalidraw artifacts, without a browser, network, or record write. @@ -75,7 +84,7 @@ Exit codes and errors: 0 success; 1 internal failure; 2 usage or interactive stdin; 3 invalid input/document; 4 build/export construction failure; 5 diagram not found; 6 conflict/busy; 7 filesystem/storage failure; 8 unavailable format, render, destination, or write failure; - 10 generate network/endpoint failure; 11 generate timeout; 12 malformed generate output; + 10 generate/canvas network or endpoint failure; 11 generate timeout; 12 malformed remote output; 13 Excalidraw transport, timeout, HTTP, or API-shape failure. Text errors start with "error: CODE". JSON errors use {"ok":false,"command":...,"error":...}. Errors never include stacks. @@ -83,5 +92,6 @@ Exit codes and errors: Revision recovery and next steps: edit, pull, and restore archive the complete prior authority state before atomic replacement. Use restore --revision N to recover through the CLI without consuming the selected snapshot. - Start with generate for a prompt or create for accepted JSON. Use the returned id with show/list, - edit with a complete replacement document, then export another stored artifact when needed. + Start with generate for a prompt, canvas for CanvasSpec, or create for accepted canonical JSON. + Every returned id supports show, list, and export. Edit and patch are only for flowchart, + mindmap, and sequence records; create a new canvas to replace a Universal CanvasSpec. diff --git a/apps/cli/src/__fixtures__/help/canvas.txt b/apps/cli/src/__fixtures__/help/canvas.txt new file mode 100644 index 00000000..1f20d285 --- /dev/null +++ b/apps/cli/src/__fixtures__/help/canvas.txt @@ -0,0 +1,42 @@ +DESCRIPTION + Build a complete Universal CanvasSpec through Sketchi's public create-canvas API, preserve the validated scene and Excalidraw result in the local store, and export PNG by default. This command is direct and noninteractive: pass exactly one CanvasSpec with --file PATH, --file - for piped stdin, or --json VALUE. + +Network and options: + Endpoint: https://playground.sketchi.app/api/v1/canvases/create + Override it for preview or local testing with SKETCHI_CANVAS_ENDPOINT or --endpoint URL. + The request asks the server for scene and Excalidraw artifacts, validates the typed response, + stores the normalized CanvasSpec plus Excalidraw output, then exports locally. --format defaults + to png; --dest defaults to .png, .excalidraw, or + .scene.json. Use --dest - for artifact-only stdout and --output json for a stable + status envelope on stderr. + +Input and failures: + Input must be the CanvasSpec object itself, not a create-canvas request wrapper and not raw + Excalidraw JSON. Interactive stdin is rejected with exit 2. Invalid JSON or CanvasSpec exits 3; + a typed server rejection exits 4; network/endpoint failure exits 10; malformed response exits 12. + The local record is committed before export, so a destination failure reports a concrete + sketchi export recovery command. The existing sketchi create command remains the strictly + offline path for accepted flowchart, mindmap, and sequence documents. + +USAGE + sketchi canvas [flags] + +FLAGS + --output text|json Result presentation format. (choices: text, json) + --file PATH|- Read one CanvasSpec document from PATH, or stdin with -. + --json VALUE Read one CanvasSpec document from inline JSON. + --endpoint URL Unauthenticated create-canvas API URL; defaults to production. + --format png|excalidraw|scene Artifact exported after the canvas is created. (choices: png, excalidraw, scene) + --dest PATH|- Artifact destination; defaults from diagramId, or - for stdout. + +GLOBAL FLAGS + --help, -h Show help information + --version, -v Show version information + --completions Print shell completion script (choices: bash, zsh, fish, sh) + +EXAMPLES + # Build a CanvasSpec, store it locally, and write its PNG. + sketchi canvas --file canvas.json --output json + + # Read CanvasSpec from stdin and export editable Excalidraw. + sketchi canvas --file - --format excalidraw --dest canvas.excalidraw diff --git a/apps/cli/src/__fixtures__/help/generate.txt b/apps/cli/src/__fixtures__/help/generate.txt index 30083168..54f35bdc 100644 --- a/apps/cli/src/__fixtures__/help/generate.txt +++ b/apps/cli/src/__fixtures__/help/generate.txt @@ -1,5 +1,5 @@ DESCRIPTION - Create one persisted diagram and export its PNG by default. With no --prompt, Sketchi opens a short wizard only when stdin and stdout are human TTYs, output is text, and CI is absent. Pipes, redirects, CI, and --output json never prompt or block; pass --prompt for every script and automation path. This is one of Sketchi's three explicit network commands (generate, share, pull). It makes one unauthenticated HTTPS POST to the public Sketchi generate API and needs no token, key, account, or login. + Create one persisted diagram and export its PNG by default. With no --prompt, Sketchi opens a short wizard only when stdin and stdout are human TTYs, output is text, and CI is absent. Pipes, redirects, CI, and --output json never prompt or block; pass --prompt for every script and automation path. This is one of Sketchi's four explicit network commands (generate, canvas, share, pull). It makes one unauthenticated HTTPS POST to the public Sketchi generate API and needs no token, key, account, or login. Everyday wizard: The wizard asks only for prompt text, flowchart (default), mind map, or sequence diagram, and a PNG destination. diff --git a/apps/cli/src/__fixtures__/help/root.txt b/apps/cli/src/__fixtures__/help/root.txt index e1b8dc27..8a1e69d9 100644 --- a/apps/cli/src/__fixtures__/help/root.txt +++ b/apps/cli/src/__fixtures__/help/root.txt @@ -5,6 +5,7 @@ START HERE sketchi generate interactive sketchi generate --prompt "Map release approval with pass and revise branches" Writes .png in this directory. No account or API key needed. + canvas Build and export a typed Universal CanvasSpec. WORK WITH A DIAGRAM show Inspect a local diagram. diff --git a/apps/cli/src/audit.test.ts b/apps/cli/src/audit.test.ts index 5e076568..22d8ab58 100644 --- a/apps/cli/src/audit.test.ts +++ b/apps/cli/src/audit.test.ts @@ -80,7 +80,7 @@ describe("CLI dependency and public-surface audit", () => { assert.notInclude(source, "NormalizedMindmapSchema"); }); - it("confines network boundaries to the three credential-free HTTPS commands", async () => { + it("confines network boundaries to the four credential-free HTTPS commands", async () => { const files = await sourceFiles(join(workspaceRoot, "apps/cli/src")); const fetchFiles: string[] = []; let cliSource = ""; @@ -93,7 +93,11 @@ describe("CLI dependency and public-surface audit", () => { } assert.deepStrictEqual( fetchFiles.map((file) => file.slice(workspaceRoot.length + 1)), - ["apps/cli/src/generation.ts", "apps/cli/src/share.ts"], + [ + "apps/cli/src/canvas.ts", + "apps/cli/src/generation.ts", + "apps/cli/src/share.ts", + ], ); assert.notInclude(cliSource, "GOOGLE_GENERATIVE_AI_API_KEY"); assert.notInclude(cliSource, "CF_AIG_TOKEN"); @@ -110,6 +114,15 @@ describe("CLI dependency and public-surface audit", () => { ); assert.notInclude(generationSource, "@sketchi/diagram-generation"); + const canvasSource = await readFile( + join(workspaceRoot, "apps/cli/src/canvas.ts"), + "utf8", + ); + assert.include( + canvasSource, + "https://playground.sketchi.app/api/v1/canvases/create", + ); + const shareProtocolSource = await readFile( join(workspaceRoot, "apps/cli/src/share-protocol.ts"), "utf8", @@ -152,6 +165,7 @@ describe("CLI dependency and public-surface audit", () => { ); assert.deepStrictEqual(commands, [ "generate", + "canvas", "docs", "create", "show", @@ -206,6 +220,7 @@ describe("CLI dependency and public-surface audit", () => { assert.notInclude(bundle, "sourceMappingURL"); assert.notInclude(bundle, "CF_AIG_TOKEN"); assert.include(bundle, "playground.sketchi.app/api/v1/generate"); + assert.include(bundle, "playground.sketchi.app/api/v1/canvases/create"); assert.include(readme, "Typed, deterministic diagrams from your terminal."); assert.include(readme, "npm install -g sketchi"); }); diff --git a/apps/cli/src/canvas-cli.test.ts b/apps/cli/src/canvas-cli.test.ts new file mode 100644 index 00000000..4c8eacee --- /dev/null +++ b/apps/cli/src/canvas-cli.test.ts @@ -0,0 +1,220 @@ +import { spawn } from "node:child_process"; +import { createServer } from "node:http"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { join, resolve } from "node:path"; + +import { CreateCanvasRequestSchema } from "@sketchi/diagram-agent"; +import { assert, describe, expect, it } from "@effect/vitest"; + +const binary = resolve(process.cwd(), "apps/cli/dist/sketchi.js"); +const testParent = resolve(process.cwd(), ".memory/cli-canvas-tests"); + +interface ProcessResult { + readonly code: number; + readonly stderr: Buffer; + readonly stdout: Buffer; +} + +function runCli( + args: ReadonlyArray, + cwd: string, + home: string, + input?: string, +): Promise { + return new Promise((complete, reject) => { + const environment: NodeJS.ProcessEnv = { ...process.env, HOME: home }; + delete environment["FORCE_COLOR"]; + delete environment["NO_COLOR"]; + const child = spawn(process.execPath, [binary, ...args], { + cwd, + env: environment, + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.on("error", reject); + child.on("close", (code) => + complete({ + code: code ?? -1, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }), + ); + child.stdin.end(input); + }); +} + +function canvasSpec(diagramId: string) { + return { + kind: "canvas", + version: 1, + diagramId, + title: "CLI Canvas", + width: 640, + height: 360, + accentColor: "#2563eb", + backgroundColor: "#ffffff", + elements: [ + { + type: "node", + id: "card", + nodeId: "card", + shape: "rectangle", + x: 40, + y: 40, + width: 240, + height: 120, + label: "Universal Canvas", + }, + ], + layers: [], + layouts: [], + zOrder: ["card"], + }; +} + +const excalidraw = { + type: "excalidraw", + version: 2, + source: "https://sketchi.app", + elements: [], + appState: {}, + files: {}, +}; + +describe("sketchi canvas command", () => { + it("accepts a file and stdin without prompting and emits deterministic JSON", async () => { + await mkdir(testParent, { recursive: true }); + const root = await mkdtemp(join(testParent, "run-")); + const home = join(root, "home"); + await mkdir(home); + const requests: ReadonlyArray[] = []; + const server = createServer((request, response) => { + const chunks: Buffer[] = []; + request.on("data", (chunk: Buffer) => chunks.push(chunk)); + request.on("end", () => { + const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString()); + const decoded = CreateCanvasRequestSchema.parse(parsed); + requests.push([decoded.spec, decoded.options]); + response.setHeader("content-type", "application/json"); + response.end( + JSON.stringify({ + ok: true, + status: "accepted", + buildId: `build-${decoded.spec.diagramId}`, + normalizedSpec: decoded.spec, + artifact: { + artifactId: `artifact-${decoded.spec.diagramId}`, + diagramId: decoded.spec.diagramId, + formats: [ + { format: "scene", mimeType: "application/json" }, + { + format: "excalidraw", + mimeType: "application/json", + inline: excalidraw, + }, + ], + }, + issues: [], + }), + ); + }); + }); + + try { + await new Promise((listening) => server.listen(0, listening)); + const address = server.address(); + if (address === null || typeof address === "string") { + throw new Error("Expected a TCP test server address."); + } + const endpoint = `http://127.0.0.1:${String(address.port)}/api/v1/canvases/create`; + + const fromFile = canvasSpec("canvas-file"); + const inputPath = join(root, "canvas.json"); + await writeFile(inputPath, JSON.stringify(fromFile)); + const fileResult = await runCli( + [ + "canvas", + "--file", + inputPath, + "--endpoint", + endpoint, + "--output", + "json", + ], + root, + home, + ); + + assert.strictEqual(fileResult.code, 0); + assert.strictEqual(fileResult.stderr.toString(), ""); + expect(JSON.parse(fileResult.stdout.toString())).toMatchObject({ + ok: true, + command: "canvas", + data: { + id: "canvas-file", + type: "canvas", + remoteArtifactId: "artifact-canvas-file", + export: { + format: "png", + destination: "canvas-file.png", + }, + }, + }); + assert.deepStrictEqual( + [...(await readFile(join(root, "canvas-file.png"))).subarray(0, 8)], + [137, 80, 78, 71, 13, 10, 26, 10], + ); + assert.deepStrictEqual( + JSON.parse( + await readFile( + join(home, ".sketchi", "diagrams", "canvas-file", "scene.json"), + "utf8", + ), + ), + fromFile, + ); + + const fromStdin = canvasSpec("canvas-stdin"); + const stdinResult = await runCli( + [ + "canvas", + "--file", + "-", + "--format", + "excalidraw", + "--dest", + "-", + "--endpoint", + endpoint, + "--output", + "json", + ], + root, + home, + JSON.stringify(fromStdin), + ); + + assert.strictEqual(stdinResult.code, 0); + assert.deepStrictEqual( + JSON.parse(stdinResult.stdout.toString()), + excalidraw, + ); + expect(JSON.parse(stdinResult.stderr.toString())).toMatchObject({ + ok: true, + command: "canvas", + data: { + id: "canvas-stdin", + remoteArtifactId: "artifact-canvas-stdin", + export: { format: "excalidraw", destination: "-" }, + }, + }); + assert.strictEqual(requests.length, 2); + } finally { + await new Promise((closed) => server.close(() => closed())); + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/apps/cli/src/canvas.test.ts b/apps/cli/src/canvas.test.ts new file mode 100644 index 00000000..aee755e6 --- /dev/null +++ b/apps/cli/src/canvas.test.ts @@ -0,0 +1,400 @@ +import { CanvasSpec, CreateCanvasRequestSchema } from "@sketchi/diagram-agent"; +import { afterEach, assert, describe, it } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; + +import { + createCanvasDiagram, + DEFAULT_CANVAS_ENDPOINT, + parseCanvasSpec, +} from "./canvas.js"; +import { + type BuiltDiagram, + DiagramRecordManifest, + type StoredDiagram, +} from "./contracts.js"; +import { exitCodeForFailure } from "./errors.js"; +import { DiagramStore } from "./storage.js"; + +const canvasSpec = Schema.decodeUnknownSync(CanvasSpec)({ + kind: "canvas", + version: 1, + diagramId: "universal-canvas", + title: "Universal Canvas", + width: 640, + height: 360, + accentColor: "#2563eb", + backgroundColor: "#ffffff", + elements: [ + { + type: "node", + id: "card", + nodeId: "card", + shape: "rectangle", + x: 40, + y: 40, + width: 240, + height: 120, + label: "Universal Canvas", + }, + ], + layers: [], + layouts: [], + zOrder: ["card"], +}); + +const excalidraw = { + type: "excalidraw", + version: 2, + source: "https://sketchi.app", + elements: [], + appState: {}, + files: {}, +}; + +function stored(diagram: BuiltDiagram): StoredDiagram { + return { + manifest: DiagramRecordManifest.make({ + schemaVersion: 1, + id: diagram.id, + type: diagram.type, + title: diagram.title, + revision: 1, + authority: "canonical", + formats: ["scene", "excalidraw"], + }), + authority: "canonical", + documentAuthoritative: true, + document: diagram.document, + revisions: [], + }; +} + +function capturingStoreLayer(created: BuiltDiagram[]) { + return Layer.succeed(DiagramStore, { + create: Effect.fn("sketchi.cli.canvas.testCreate")(function* (diagram) { + created.push(diagram); + return stored(diagram); + }), + edit: () => Effect.die("unused edit"), + readPatchSource: () => Effect.die("unused readPatchSource"), + commitPatch: () => Effect.die("unused commitPatch"), + show: () => Effect.die("unused show"), + list: () => Effect.die("unused list"), + replaceWithDetached: () => Effect.die("unused replaceWithDetached"), + readRevision: () => Effect.die("unused readRevision"), + restore: () => Effect.die("unused restore"), + readExportSource: () => Effect.die("unused readExportSource"), + }); +} + +function acceptedResponse(): Response { + return Response.json({ + ok: true, + status: "accepted", + buildId: "canvas-build-1", + normalizedSpec: canvasSpec, + artifact: { + artifactId: "canvas-artifact-1", + diagramId: canvasSpec.diagramId, + formats: [ + { format: "scene", mimeType: "application/json" }, + { + format: "excalidraw", + mimeType: "application/json", + inline: excalidraw, + }, + ], + }, + issues: [], + }); +} + +const originalFetch = globalThis.fetch; + +describe("Universal Canvas public API client", () => { + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it.effect( + "submits CanvasSpec and preserves the validated built diagram", + () => { + const created: BuiltDiagram[] = []; + let decodedRequest: + | ReturnType + | undefined; + globalThis.fetch = (input, init) => { + const request = new Request(input, init); + return request.json().then((body) => { + decodedRequest = CreateCanvasRequestSchema.parse(body); + return acceptedResponse(); + }); + }; + + return Effect.gen(function* () { + const result = yield* createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: canvasSpec, + }); + + assert.strictEqual(result.artifactId, "canvas-artifact-1"); + assert.strictEqual(result.diagram.manifest.type, "canvas"); + assert.strictEqual(result.diagram.document.type, "canvas"); + assert.strictEqual(created.length, 1); + assert.deepStrictEqual(decodedRequest?.options?.artifactFormats, [ + "scene", + "excalidraw", + ]); + assert.deepStrictEqual(decodedRequest?.options?.inlineArtifacts, [ + "excalidraw", + ]); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }, + ); + + it.effect("rejects malformed CanvasSpec before any request", () => + Effect.gen(function* () { + let requested = false; + globalThis.fetch = () => { + requested = true; + return Promise.resolve(acceptedResponse()); + }; + const error = yield* Effect.flip( + parseCanvasSpec('{"kind":"canvas","version":1}'), + ); + + assert.strictEqual(error._tag, "CliValidationError"); + assert.isFalse(requested); + }), + ); + + it.effect( + "reports an unsafe CanvasSpec diagramId at the correct path", + () => { + const created: BuiltDiagram[] = []; + let requested = false; + globalThis.fetch = () => { + requested = true; + return Promise.resolve(acceptedResponse()); + }; + + return Effect.gen(function* () { + const error = yield* Effect.flip( + createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: { ...canvasSpec, diagramId: "unsafe/id" }, + }), + ); + + assert.strictEqual(error._tag, "CliValidationError"); + if (error._tag === "CliValidationError") { + assert.deepStrictEqual(error.details, ["spec.diagramId"]); + } + assert.isFalse(requested); + assert.strictEqual(created.length, 0); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }, + ); + + it.effect("maps a typed server rejection without committing", () => { + const created: BuiltDiagram[] = []; + globalThis.fetch = () => + Promise.resolve( + Response.json( + { + ok: false, + status: "invalid_canvas", + issues: [ + { + code: "invalid_canvas_geometry", + severity: "error", + stage: "canvas", + ref: { kind: "diagram", path: "elements" }, + message: "Canvas geometry is invalid.", + hint: "Correct the CanvasSpec geometry.", + }, + ], + }, + { status: 422 }, + ), + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: canvasSpec, + }), + ); + + assert.strictEqual(error._tag, "CliCanvasError"); + if (error._tag === "CliCanvasError") { + assert.strictEqual(error.code, "canvas_rejected"); + assert.include(error.details, "http_status:422"); + } + assert.strictEqual(created.length, 0); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }); + + const serverFailures = [ + { + status: "render_failed", + issueCode: "render_failed", + issueStage: "render", + }, + { + status: "export_failed", + issueCode: "export_invalid_scene", + issueStage: "export", + }, + { + status: "storage_failed", + issueCode: "storage_write_failed", + issueStage: "storage", + }, + ] satisfies ReadonlyArray<{ + readonly status: "export_failed" | "render_failed" | "storage_failed"; + readonly issueCode: + | "export_invalid_scene" + | "render_failed" + | "storage_write_failed"; + readonly issueStage: "export" | "render" | "storage"; + }>; + + for (const { issueCode, issueStage, status } of serverFailures) { + it.effect( + `maps ${status} to an endpoint failure without committing`, + () => { + const created: BuiltDiagram[] = []; + globalThis.fetch = () => + Promise.resolve( + Response.json( + { + ok: false, + status, + issues: [ + { + code: issueCode, + severity: "error", + stage: issueStage, + ref: { kind: "diagram" }, + message: `Server reported ${status}.`, + hint: "Retry later.", + }, + ], + }, + { status: 500 }, + ), + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: canvasSpec, + }), + ); + + assert.strictEqual(error._tag, "CliCanvasError"); + if (error._tag === "CliCanvasError") { + assert.strictEqual(error.code, "endpoint_failure"); + assert.include(error.details, `status:${status}`); + assert.strictEqual(exitCodeForFailure(error), 10); + } + assert.strictEqual(created.length, 0); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }, + ); + } + + it.effect( + "rejects a normalized CanvasSpec with a different diagramId", + () => { + const created: BuiltDiagram[] = []; + globalThis.fetch = () => + Promise.resolve( + Response.json({ + ok: true, + status: "accepted", + buildId: "canvas-build-renamed", + normalizedSpec: { ...canvasSpec, diagramId: "renamed-canvas" }, + artifact: { + artifactId: "canvas-artifact-renamed", + diagramId: "renamed-canvas", + formats: [ + { format: "scene", mimeType: "application/json" }, + { + format: "excalidraw", + mimeType: "application/json", + inline: excalidraw, + }, + ], + }, + issues: [], + }), + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: canvasSpec, + }), + ); + + assert.strictEqual(error._tag, "CliCanvasError"); + if (error._tag === "CliCanvasError") { + assert.strictEqual(error.code, "malformed_response"); + assert.include( + error.details, + "normalizedSpec.diagramId does not match submitted spec.diagramId", + ); + } + assert.strictEqual(created.length, 0); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }, + ); + + it.effect("rejects malformed accepted responses without committing", () => { + const created: BuiltDiagram[] = []; + globalThis.fetch = () => + Promise.resolve( + Response.json({ ok: true, status: "accepted" }, { status: 200 }), + ); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: canvasSpec, + }), + ); + + assert.strictEqual(error._tag, "CliCanvasError"); + if (error._tag === "CliCanvasError") { + assert.strictEqual(error.code, "malformed_response"); + } + assert.strictEqual(created.length, 0); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }); + + it.effect("maps network failure without committing", () => { + const created: BuiltDiagram[] = []; + globalThis.fetch = () => Promise.reject(new Error("offline")); + + return Effect.gen(function* () { + const error = yield* Effect.flip( + createCanvasDiagram({ + endpoint: DEFAULT_CANVAS_ENDPOINT, + spec: canvasSpec, + }), + ); + + assert.strictEqual(error._tag, "CliCanvasError"); + if (error._tag === "CliCanvasError") { + assert.strictEqual(error.code, "network_failure"); + } + assert.strictEqual(created.length, 0); + }).pipe(Effect.provide(capturingStoreLayer(created))); + }); +}); diff --git a/apps/cli/src/canvas.ts b/apps/cli/src/canvas.ts new file mode 100644 index 00000000..1848d843 --- /dev/null +++ b/apps/cli/src/canvas.ts @@ -0,0 +1,283 @@ +import { + CanvasSpec, + CreateCanvasResultSchema, + ExcalidrawFileSchema, +} from "@sketchi/diagram-agent"; +import { Effect, Schema, SchemaIssue } from "effect"; + +import type { BuiltDiagram, StoredDiagram } from "./contracts.js"; +import { validateStorageId } from "./document.js"; +import { CliCanvasError, CliInputError, CliValidationError } from "./errors.js"; +import type { InputSource } from "./internal/effect-unstable-cli.js"; +import { InputReader } from "./input.js"; +import { DiagramStore } from "./storage.js"; + +export const DEFAULT_CANVAS_ENDPOINT = + "https://playground.sketchi.app/api/v1/canvases/create"; +export const SKETCHI_CANVAS_ENDPOINT_ENV = "SKETCHI_CANVAS_ENDPOINT"; + +export interface CreateCanvasInput { + readonly endpoint: string; + readonly spec: CanvasSpec; +} + +export interface CreateCanvasDiagramResult { + readonly artifactId: string; + readonly diagram: StoredDiagram; +} + +export function resolveCanvasEndpoint(): string { + return ( + process.env[SKETCHI_CANVAS_ENDPOINT_ENV]?.trim() || DEFAULT_CANVAS_ENDPOINT + ); +} + +const formatSchemaIssue = SchemaIssue.makeFormatterStandardSchemaV1(); + +function schemaDetails(error: Schema.SchemaError): ReadonlyArray { + return formatSchemaIssue(error.issue).issues.map((issue) => { + const path = (issue.path ?? []) + .map((segment) => { + if ( + typeof segment === "object" && + segment !== null && + "key" in segment + ) { + return String(segment.key); + } + return String(segment); + }) + .join("."); + return `${path || "spec"}: ${issue.message}`; + }); +} + +const decodeCanvasSpec = Schema.decodeUnknownEffect(CanvasSpec, { + errors: "all", +}); +const decodeCreateCanvasResult = Schema.decodeUnknownEffect( + CreateCanvasResultSchema, + { errors: "all" }, +); + +export const parseCanvasSpec = Effect.fn("sketchi.cli.canvas.parseSpec")( + function* (text: string) { + const input: unknown = yield* Effect.try({ + try: () => JSON.parse(text), + catch: () => + CliInputError.make({ + code: "invalid_json", + message: "The CanvasSpec input is not valid JSON.", + hint: "Pass one complete CanvasSpec JSON object.", + }), + }); + return yield* decodeCanvasSpec(input).pipe( + Effect.mapError((error) => + CliValidationError.make({ + message: "The CanvasSpec document is invalid.", + hint: "Use the version 1 CanvasSpec contract documented by sketchi docs.", + details: schemaDetails(error), + }), + ), + ); + }, +); + +export const readCanvasSpecInput = Effect.fn( + "sketchi.cli.canvas.readSpecInput", +)(function* (source: InputSource) { + const reader = yield* InputReader; + const text = yield* reader.read(source, { content: "CanvasSpec document" }); + return yield* parseCanvasSpec(text); +}); + +function networkFailure(): CliCanvasError { + return CliCanvasError.make({ + code: "network_failure", + message: "The Sketchi create-canvas API could not be reached.", + hint: "Check the network connection and endpoint, then retry.", + details: ["transport"], + }); +} + +function malformedResponse( + details: ReadonlyArray = [], +): CliCanvasError { + return CliCanvasError.make({ + code: "malformed_response", + message: "The Sketchi create-canvas API returned an unreadable response.", + hint: "Retry once; if the response remains invalid, report the endpoint.", + details, + }); +} + +function endpointFailure(status: number): CliCanvasError { + return CliCanvasError.make({ + code: "endpoint_failure", + message: `The Sketchi create-canvas API responded with HTTP ${String(status)}.`, + hint: "Check the endpoint and retry.", + details: [`http_status:${String(status)}`], + }); +} + +function serverCanvasFailure( + status: "export_failed" | "render_failed" | "storage_failed", + httpStatus: number, + issues: ReadonlyArray<{ + readonly code: string; + readonly message: string; + }>, +): CliCanvasError { + return CliCanvasError.make({ + code: "endpoint_failure", + message: `The Sketchi create-canvas API failed with status ${status}.`, + hint: "Retry once; if the failure persists, report the endpoint and status.", + details: [ + `status:${status}`, + `http_status:${String(httpStatus)}`, + ...issues.map((issue) => `${issue.code}: ${issue.message}`), + ], + }); +} + +function rejectedCanvas( + status: string, + httpStatus: number, + issues: ReadonlyArray<{ + readonly code: string; + readonly message: string; + }>, +): CliCanvasError { + const first = issues[0]; + return CliCanvasError.make({ + code: "canvas_rejected", + message: first?.message ?? "The create-canvas API rejected the CanvasSpec.", + hint: "Correct the reported CanvasSpec issue and retry.", + details: [ + `status:${status}`, + `http_status:${String(httpStatus)}`, + ...issues.map((issue) => `${issue.code}: ${issue.message}`), + ], + }); +} + +const requestCanvas = Effect.fn("sketchi.cli.canvas.request")(function* ( + input: CreateCanvasInput, +) { + const response = yield* Effect.tryPromise({ + try: (signal) => + globalThis.fetch(input.endpoint, { + method: "POST", + headers: { + "content-type": "application/json", + "x-sketchi-client": "sketchi-cli", + }, + body: JSON.stringify({ + spec: input.spec, + options: { + artifactFormats: ["scene", "excalidraw"], + inlineArtifacts: ["excalidraw"], + }, + }), + signal, + }), + catch: networkFailure, + }); + const text = yield* Effect.tryPromise({ + try: () => response.text(), + catch: networkFailure, + }); + const parsed: unknown = yield* Effect.try({ + try: () => JSON.parse(text), + catch: () => malformedResponse(), + }); + const result = yield* decodeCreateCanvasResult(parsed).pipe( + Effect.mapError((error) => + response.ok + ? malformedResponse(schemaDetails(error)) + : endpointFailure(response.status), + ), + ); + if (!result.ok) { + switch (result.status) { + case "export_failed": + case "render_failed": + case "storage_failed": + return yield* serverCanvasFailure( + result.status, + response.status, + result.issues, + ); + case "invalid_canvas": + case "invalid_input": + case "limit_exceeded": + return yield* rejectedCanvas( + result.status, + response.status, + result.issues, + ); + } + } + if (!response.ok) return yield* endpointFailure(response.status); + return result; +}); + +export const createCanvasDiagram = Effect.fn("sketchi.cli.canvas.create")( + function* (input: CreateCanvasInput) { + const store = yield* DiagramStore; + yield* validateStorageId(input.spec.diagramId).pipe( + Effect.mapError((error) => + CliValidationError.make({ + message: error.message, + hint: error.hint, + details: ["spec.diagramId"], + }), + ), + ); + const response = yield* requestCanvas(input); + if (response.normalizedSpec.diagramId !== input.spec.diagramId) { + return yield* malformedResponse([ + "normalizedSpec.diagramId does not match submitted spec.diagramId", + ]); + } + const excalidrawReference = response.artifact.formats.find( + (artifact) => artifact.format === "excalidraw", + ); + if (excalidrawReference?.inline === undefined) { + return yield* malformedResponse(["artifact.formats.excalidraw.inline"]); + } + const decodedExcalidraw = ExcalidrawFileSchema.safeParse( + excalidrawReference.inline, + ); + if (!decodedExcalidraw.success) { + return yield* malformedResponse( + decodedExcalidraw.error.issues.map( + (issue) => `${issue.path.join(".")}: ${issue.message}`, + ), + ); + } + const id = yield* validateStorageId(response.normalizedSpec.diagramId).pipe( + Effect.mapError(() => + malformedResponse(["normalizedSpec.diagramId is not storage-safe"]), + ), + ); + if (response.artifact.diagramId !== id) { + return yield* malformedResponse([ + "artifact.diagramId does not match normalizedSpec.diagramId", + ]); + } + const built: BuiltDiagram = { + id, + type: "canvas", + title: response.normalizedSpec.title, + document: { type: "canvas", spec: response.normalizedSpec }, + scene: response.normalizedSpec, + excalidraw: decodedExcalidraw.data, + }; + const diagram = yield* store.create(built); + return { + artifactId: response.artifact.artifactId, + diagram, + } satisfies CreateCanvasDiagramResult; + }, +); diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index 3fa5cd67..342a31a2 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -5,6 +5,14 @@ import { import { Cause, Effect, Layer, Option } from "effect"; import { DiagramBuilder, DiagramBuilderLive } from "./builder.js"; +import { + createCanvasDiagram, + DEFAULT_CANVAS_ENDPOINT, + readCanvasSpecInput, + resolveCanvasEndpoint, + SKETCHI_CANVAS_ENDPOINT_ENV, + type CreateCanvasDiagramResult, +} from "./canvas.js"; import { type DiagramFormat, type DiagramSummary, @@ -22,6 +30,7 @@ import { import { DiagramExporter, DiagramExporterLive } from "./exporter.js"; import { LocalFileSystem, LocalFileSystemLive } from "./filesystem.js"; import { + exportCreatedDiagram, runGenerateWorkflow, type GenerateWorkflowResult, type GenerationDestination, @@ -94,8 +103,10 @@ import { const AGENT_DOCS = `Sketchi CLI contracts for agents and automation. Command map: - Start with generate for prompt-to-PNG. Use show, edit, patch, list, restore, and export for - local records. Use create for accepted canonical JSON. Share and pull are explicit network + Start with generate for prompt-to-PNG or canvas for an authored CanvasSpec. Use show, list, + and export for every local record. Edit and patch support canonical flowchart, mindmap, and + sequence records. Use create for accepted canonical JSON. + Share and pull are explicit network boundaries for Excalidraw links. Run sketchi COMMAND --help for every flag and example. Manual JSON workflow (strictly offline): @@ -112,11 +123,15 @@ Canonical mindmap example: Canonical sequence example: {"type":"sequence","spec":{"id":"checkout-sequence","title":"Checkout sequence","participants":[{"id":"browser","label":"Browser"},{"id":"api","label":"API"}],"messages":[{"id":"request","source":"browser","target":"api","label":"Submit checkout"}]}} +Universal CanvasSpec example: + {"kind":"canvas","version":1,"diagramId":"release-board","title":"Release board","width":640,"height":360,"accentColor":"#2563eb","backgroundColor":"#ffffff","elements":[{"type":"node","id":"ready","nodeId":"ready","shape":"rectangle","x":40,"y":40,"width":240,"height":120,"label":"Ready to release"}],"layers":[],"layouts":[],"zOrder":["ready"]} + Semantic color patch example: sketchi patch release-flow --json '{"operations":[{"op":"setStyle","selector":{"nodeIds":["review","approve"]},"style":{"fillColor":"#dbeafe","strokeColor":"#2563eb","textColor":"#1e3a8a"}}]}' Explicit network commands (one credential-free HTTPS request each): sketchi generate [--prompt TEXT] [--type flowchart|mindmap|sequence|er|architecture|swimlane|state-machine] [--model MODEL] + sketchi canvas --file PATH|- [--format png|excalidraw|scene] [--dest PATH|-] sketchi share DIAGRAM_ID [--open] sketchi pull DIAGRAM_ID --link URL|- generate makes one unauthenticated HTTPS POST to the public Sketchi generate API at ${DEFAULT_GENERATE_ENDPOINT} @@ -125,9 +140,12 @@ Explicit network commands (one credential-free HTTPS request each): through the same local store as create. Without --type, the model selects a supported native type; the default model is ${DEFAULT_GENERATION_MODEL}. Override the endpoint for preview or local testing with ${SKETCHI_GENERATE_ENDPOINT_ENV} or --endpoint URL. + canvas submits the CanvasSpec object itself to ${DEFAULT_CANVAS_ENDPOINT}, validates the typed + response, stores the normalized scene and Excalidraw artifact, and exports locally. Override its + endpoint with ${SKETCHI_CANVAS_ENDPOINT_ENV} or --endpoint URL. Input and output contracts: - create/edit/patch require exactly one of --file PATH|- or --json VALUE. --file - reads one + create/edit/patch/canvas require exactly one of --file PATH|- or --json VALUE. --file - reads one noninteractive UTF-8 JSON document from stdin and exits with usage code 2 on a TTY. --json is inline input only. --prompt is always direct and noninteractive. With no --prompt, generate opens a short prompt/type/PNG-destination wizard only when stdin and stdout are human @@ -159,7 +177,7 @@ Offline boundary and storage: A pulled record is detached: diagram.excalidraw is authoritative while document.json and scene.json remain non-authoritative provenance. A patched record makes scene.json authoritative while retaining document.json as provenance. show/list expose these states; edit refuses both. - Formats are scene, excalidraw, and png. generate persists the canonical record before exporting + Formats are scene, excalidraw, and png. generate and canvas persist the local record before exporting the requested artifact; a later destination failure leaves that record recoverable. If no PNG is stored, export deterministically renders one on demand from the local scene and Excalidraw artifacts, without a browser, network, or record write. @@ -168,7 +186,7 @@ Exit codes and errors: 0 success; 1 internal failure; 2 usage or interactive stdin; 3 invalid input/document; 4 build/export construction failure; 5 diagram not found; 6 conflict/busy; 7 filesystem/storage failure; 8 unavailable format, render, destination, or write failure; - 10 generate network/endpoint failure; 11 generate timeout; 12 malformed generate output; + 10 generate/canvas network or endpoint failure; 11 generate timeout; 12 malformed remote output; 13 Excalidraw transport, timeout, HTTP, or API-shape failure. Text errors start with "error: CODE". JSON errors use {"ok":false,"command":...,"error":...}. Errors never include stacks. @@ -176,8 +194,9 @@ Exit codes and errors: Revision recovery and next steps: edit, pull, and restore archive the complete prior authority state before atomic replacement. Use restore --revision N to recover through the CLI without consuming the selected snapshot. - Start with generate for a prompt or create for accepted JSON. Use the returned id with show/list, - edit with a complete replacement document, then export another stored artifact when needed.`; + Start with generate for a prompt, canvas for CanvasSpec, or create for accepted canonical JSON. + Every returned id supports show, list, and export. Edit and patch are only for flowchart, + mindmap, and sequence records; create a new canvas to replace a Universal CanvasSpec.`; const HUMAN_HELP = "Turn one prompt into a validated PNG and editable local diagram."; @@ -397,7 +416,7 @@ function isNativeGenerationType(value: string): value is GenerationType { return value === "flowchart" || value === "mindmap" || value === "sequence"; } -const GENERATE_HELP = `Create one persisted diagram and export its PNG by default. With no --prompt, Sketchi opens a short wizard only when stdin and stdout are human TTYs, output is text, and CI is absent. Pipes, redirects, CI, and --output json never prompt or block; pass --prompt for every script and automation path. This is one of Sketchi's three explicit network commands (generate, share, pull). It makes one unauthenticated HTTPS POST to the public Sketchi generate API and needs no token, key, account, or login. +const GENERATE_HELP = `Create one persisted diagram and export its PNG by default. With no --prompt, Sketchi opens a short wizard only when stdin and stdout are human TTYs, output is text, and CI is absent. Pipes, redirects, CI, and --output json never prompt or block; pass --prompt for every script and automation path. This is one of Sketchi's four explicit network commands (generate, canvas, share, pull). It makes one unauthenticated HTTPS POST to the public Sketchi generate API and needs no token, key, account, or login. Everyday wizard: The wizard asks only for prompt text, flowchart (default), mind map, or sequence diagram, and a PNG destination. @@ -597,6 +616,128 @@ const generateCommand = Command.make( ]), ); +interface CanvasCommandResult extends CreateCanvasDiagramResult { + readonly artifact: GenerateWorkflowResult["artifact"]; +} + +function canvasData(result: CanvasCommandResult) { + return { + ...storedData(result.diagram), + remoteArtifactId: result.artifactId, + export: exportData(result.artifact), + }; +} + +function canvasText(result: CanvasCommandResult): string { + const hint = displayHint(result.artifact); + return [ + summaryText("created", result.diagram).replace(/^created:/u, "canvas:"), + `remote artifact: ${result.artifactId}`, + `format: ${result.artifact.format}`, + `destination: ${result.artifact.destination}`, + `bytes: ${String(result.artifact.sizeBytes)}`, + ...(hint ? [`hint: ${hint}`] : []), + ].join("\n"); +} + +const CANVAS_HELP = `Build a complete Universal CanvasSpec through Sketchi's public create-canvas API, preserve the validated scene and Excalidraw result in the local store, and export PNG by default. This command is direct and noninteractive: pass exactly one CanvasSpec with --file PATH, --file - for piped stdin, or --json VALUE. + +Network and options: + Endpoint: ${DEFAULT_CANVAS_ENDPOINT} + Override it for preview or local testing with ${SKETCHI_CANVAS_ENDPOINT_ENV} or --endpoint URL. + The request asks the server for scene and Excalidraw artifacts, validates the typed response, + stores the normalized CanvasSpec plus Excalidraw output, then exports locally. --format defaults + to png; --dest defaults to .png, .excalidraw, or + .scene.json. Use --dest - for artifact-only stdout and --output json for a stable + status envelope on stderr. + +Input and failures: + Input must be the CanvasSpec object itself, not a create-canvas request wrapper and not raw + Excalidraw JSON. Interactive stdin is rejected with exit 2. Invalid JSON or CanvasSpec exits 3; + a typed server rejection exits 4; network/endpoint failure exits 10; malformed response exits 12. + The local record is committed before export, so a destination failure reports a concrete + sketchi export recovery command. The existing sketchi create command remains the strictly + offline path for accepted flowchart, mindmap, and sequence documents.`; + +const canvasCommand = Command.make( + "canvas", + { + ...exclusiveInputSourceFlags("CanvasSpec document"), + endpoint: Flag.string("endpoint").pipe( + Flag.withDefault(resolveCanvasEndpoint()), + Flag.withDescription( + "Unauthenticated create-canvas API URL; defaults to production.", + ), + Flag.withMetavar("URL"), + ), + format: Flag.choice("format", ["png", "excalidraw", "scene"]).pipe( + Flag.withDefault("png"), + Flag.withDescription("Artifact exported after the canvas is created."), + Flag.withMetavar("png|excalidraw|scene"), + ), + destination: Flag.optional( + Flag.string("dest").pipe( + Flag.withDescription( + "Artifact destination; defaults from diagramId, or - for stdout.", + ), + Flag.withMetavar("PATH|-"), + ), + ), + }, + ({ destination, endpoint, format, source }) => + Effect.gen(function* () { + const { output } = yield* rootCommand; + const operation = Effect.gen(function* () { + const spec = yield* readCanvasSpecInput(source); + const created = yield* createCanvasDiagram({ endpoint, spec }); + const artifact = yield* exportCreatedDiagram( + created.diagram, + format, + Option.match(destination, { + onNone: () => ({ _tag: "Default" }), + onSome: (path) => ({ _tag: "Custom", path }), + }), + ); + return { ...created, artifact } satisfies CanvasCommandResult; + }); + yield* operation.pipe( + Effect.matchEffect({ + onFailure: (error) => reportFailure("canvas", output, error), + onSuccess: (result) => + Effect.gen(function* () { + const writer = yield* OutputWriter; + if (result.artifact.stdoutBytes) { + yield* writer.stdout(result.artifact.stdoutBytes); + } + yield* reportSuccess( + "canvas", + output, + canvasData(result), + canvasText(result), + result.artifact.destination === "-" ? "stderr" : "stdout", + ); + }), + }), + ); + }), +).pipe( + Command.withDescription(CANVAS_HELP), + Command.withShortDescription( + "Build and export a typed Universal CanvasSpec.", + ), + Command.withExamples([ + { + command: "sketchi canvas --file canvas.json --output json", + description: "Build a CanvasSpec, store it locally, and write its PNG.", + }, + { + command: + "sketchi canvas --file - --format excalidraw --dest canvas.excalidraw", + description: "Read CanvasSpec from stdin and export editable Excalidraw.", + }, + ]), +); + const createCommand = Command.make( "create", exclusiveInputSourceFlags(), @@ -1069,6 +1210,7 @@ const exportCommand = Command.make( export const sketchiCommand = rootCommand.pipe( Command.withSubcommands([ generateCommand, + canvasCommand, docsCommand, createCommand, showCommand, diff --git a/apps/cli/src/contracts.ts b/apps/cli/src/contracts.ts index 8cae9610..a21c457e 100644 --- a/apps/cli/src/contracts.ts +++ b/apps/cli/src/contracts.ts @@ -1,7 +1,7 @@ import type { ExcalidrawFile, PatchableScene } from "@sketchi/diagram-agent"; import { Effect, Schema } from "effect"; -import type { CanonicalDiagramDocument } from "./document.js"; +import type { DiagramDocument } from "./document.js"; export const RECORD_SCHEMA_VERSION = 1; export const MANIFEST_FILE = "manifest.json"; @@ -20,7 +20,7 @@ export class DiagramRecordManifest extends Schema.Class( )({ schemaVersion: Schema.Literal(RECORD_SCHEMA_VERSION), id: Schema.String, - type: Schema.Literals(["flowchart", "mindmap", "sequence"]), + type: Schema.Literals(["canvas", "flowchart", "mindmap", "sequence"]), title: Schema.String, revision: Schema.Int.check(Schema.isGreaterThan(0)), authority: Schema.Literals(["canonical", "patched", "detached"]).pipe( @@ -31,9 +31,9 @@ export class DiagramRecordManifest extends Schema.Class( export interface BuiltDiagram { readonly id: string; - readonly type: CanonicalDiagramDocument["type"]; + readonly type: DiagramDocument["type"]; readonly title: string; - readonly document: CanonicalDiagramDocument; + readonly document: DiagramDocument; readonly scene: PatchableScene; readonly excalidraw: ExcalidrawFile; readonly png?: Uint8Array; @@ -51,7 +51,7 @@ export interface PatchedDiagramArtifacts { export interface StoredDiagram { readonly manifest: DiagramRecordManifest; - readonly document: CanonicalDiagramDocument; + readonly document: DiagramDocument; readonly revisions: ReadonlyArray; readonly authority: DiagramAuthority; readonly documentAuthoritative: boolean; @@ -59,7 +59,7 @@ export interface StoredDiagram { export interface DiagramSummary { readonly id: string; - readonly type: CanonicalDiagramDocument["type"]; + readonly type: DiagramDocument["type"]; readonly title: string; readonly revision: number; readonly formats: ReadonlyArray; diff --git a/apps/cli/src/document.ts b/apps/cli/src/document.ts index 235cfc3a..1cadc5db 100644 --- a/apps/cli/src/document.ts +++ b/apps/cli/src/document.ts @@ -1,4 +1,5 @@ import { + CanvasSpec, FlowchartSpec, MindmapSpec, SequenceDiagramSpec, @@ -35,6 +36,13 @@ export type CanonicalDiagramDocument = | { readonly type: "mindmap"; readonly spec: MindmapSpec } | { readonly type: "sequence"; readonly spec: SequenceDiagramSpec }; +export type CanvasDiagramDocument = { + readonly type: "canvas"; + readonly spec: CanvasSpec; +}; + +export type DiagramDocument = CanonicalDiagramDocument | CanvasDiagramDocument; + const formatSchemaIssue = SchemaIssue.makeFormatterStandardSchemaV1(); const decodeFlowchartSpec = Schema.decodeUnknownEffect(FlowchartSpec, { errors: "all", @@ -45,6 +53,9 @@ const decodeMindmapSpec = Schema.decodeUnknownEffect(MindmapSpec, { const decodeSequenceSpec = Schema.decodeUnknownEffect(SequenceDiagramSpec, { errors: "all", }); +const decodeCanvasSpec = Schema.decodeUnknownEffect(CanvasSpec, { + errors: "all", +}); function validationError(details: ReadonlyArray) { return CliValidationError.make({ @@ -76,6 +87,10 @@ function sequenceDocument(spec: SequenceDiagramSpec): CanonicalDiagramDocument { return { type: "sequence", spec }; } +function canvasDocument(spec: CanvasSpec): CanvasDiagramDocument { + return { type: "canvas", spec }; +} + function documentFields( input: unknown, ): { readonly type: unknown; readonly spec: unknown } | undefined { @@ -139,6 +154,20 @@ export const parseJsonDocument = Effect.fn("sketchi.cli.document.parseJson")( }, ); +/** Decode every document shape that the local store can own. */ +export const decodeStoredDiagramDocument = Effect.fn( + "sketchi.cli.document.decodeStored", +)(function* (input: unknown) { + const fields = documentFields(input); + if (fields?.type !== "canvas") { + return yield* decodeCanonicalDiagramDocument(input); + } + const spec = yield* decodeCanvasSpec(fields.spec).pipe( + Effect.mapError((error) => validationError(schemaDetails(error.issue))), + ); + return canvasDocument(spec); +}); + export function encodeJson(value: unknown): string { return `${JSON.stringify(value, null, 2)}\n`; } diff --git a/apps/cli/src/errors.ts b/apps/cli/src/errors.ts index d5dcce11..b06a9bca 100644 --- a/apps/cli/src/errors.ts +++ b/apps/cli/src/errors.ts @@ -58,6 +58,21 @@ export class CliGenerationError extends Schema.TaggedErrorClass()( + "CliCanvasError", + { + code: Schema.Literals([ + "canvas_rejected", + "endpoint_failure", + "malformed_response", + "network_failure", + ]), + message: Schema.String, + hint: Schema.String, + details: Schema.Array(Schema.String), + }, +) {} + export class CliInteractiveError extends Schema.TaggedErrorClass()( "CliInteractiveError", { @@ -138,6 +153,7 @@ export type CliFailure = | CliInputError | CliValidationError | CliBuildError + | CliCanvasError | CliGenerationError | CliInteractiveError | CliStorageError @@ -164,6 +180,16 @@ export function exitCodeForFailure(error: CliFailure): number { case "malformed_output": return 12; } + case "CliCanvasError": + switch (error.code) { + case "canvas_rejected": + return 4; + case "endpoint_failure": + case "network_failure": + return 10; + case "malformed_response": + return 12; + } case "CliInteractiveError": return error.code === "cancelled" ? 2 : 1; case "CliStorageError": diff --git a/apps/cli/src/generate-workflow.test.ts b/apps/cli/src/generate-workflow.test.ts index 05acef8f..39d91307 100644 --- a/apps/cli/src/generate-workflow.test.ts +++ b/apps/cli/src/generate-workflow.test.ts @@ -1,11 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; import { CliExportError, CliFilesystemError } from "./errors.js"; -import { preserveGeneratedRecordOnExportFailure } from "./generate-workflow.js"; +import { preserveCreatedRecordOnExportFailure } from "./generate-workflow.js"; describe("post-generation export recovery", () => { it("attaches the persisted identity and a concrete retry to write failures", () => { - const failure = preserveGeneratedRecordOnExportFailure( + const failure = preserveCreatedRecordOnExportFailure( CliExportError.make({ code: "export_write_failed", format: "proof.png", @@ -25,11 +25,11 @@ describe("post-generation export recovery", () => { failure.recoveryCommand, "sketchi export release-approval --format png --dest release-approval.png", ); - assert.include(failure.hint, "The canonical record is preserved."); + assert.include(failure.hint, "The local record is preserved."); }); it("turns a destination filesystem failure into a recoverable export error", () => { - const failure = preserveGeneratedRecordOnExportFailure( + const failure = preserveCreatedRecordOnExportFailure( CliFilesystemError.make({ cause: new Error("permission denied"), operation: "mkdir", diff --git a/apps/cli/src/generate-workflow.ts b/apps/cli/src/generate-workflow.ts index 5c950800..1a3cb0e6 100644 --- a/apps/cli/src/generate-workflow.ts +++ b/apps/cli/src/generate-workflow.ts @@ -2,7 +2,7 @@ import { join, resolve } from "node:path"; import { Effect } from "effect"; -import type { DiagramFormat } from "./contracts.js"; +import type { DiagramFormat, StoredDiagram } from "./contracts.js"; import { DiagramExporter } from "./exporter.js"; import { generateDiagram, @@ -76,13 +76,56 @@ function resolveDestination( } } +export const exportCreatedDiagram = Effect.fn( + "sketchi.cli.exportCreatedDiagram", +)(function* ( + diagram: StoredDiagram, + format: DiagramFormat, + destinationInput: GenerationDestination, +) { + const exporter = yield* DiagramExporter; + const id = diagram.manifest.id; + const destination = resolveDestination(id, format, destinationInput); + const bytes = yield* exporter + .exportArtifact(id, format) + .pipe( + Effect.mapError((error) => + preserveCreatedRecordOnExportFailure(error, id, format), + ), + ); + + yield* Effect.gen(function* () { + if (destination === "-") return; + if (destinationInput._tag === "ProjectDiagrams") { + const filesystem = yield* LocalFileSystem; + yield* filesystem.makeDirectory( + join(destinationInput.cwd, "diagrams"), + true, + ); + } + yield* writeExportFile(destination, bytes); + }).pipe( + Effect.mapError((error) => + preserveCreatedRecordOnExportFailure(error, id, format), + ), + ); + + return { + id, + format, + destination, + sizeBytes: bytes.byteLength, + ...(destination === "-" ? { stdoutBytes: bytes } : {}), + } satisfies GeneratedArtifact; +}); + type PostGenerationExportFailure = | CliExportError | CliFilesystemError | CliStorageError; /** Attach the already-committed record and a safe retry to every export-stage failure. */ -export function preserveGeneratedRecordOnExportFailure( +export function preserveCreatedRecordOnExportFailure( error: PostGenerationExportFailure, diagramId: string, format: DiagramFormat, @@ -108,60 +151,22 @@ export function preserveGeneratedRecordOnExportFailure( ...(details ? { details } : {}), format, message: error.message, - hint: `The canonical record is preserved. Retry with: ${recoveryCommand}`, + hint: `The local record is preserved. Retry with: ${recoveryCommand}`, }); } export const runGenerateWorkflow = Effect.fn("sketchi.cli.generateWorkflow")( function* (input: GenerateWorkflowInput) { - const exporter = yield* DiagramExporter; const generated = yield* generateDiagram(input); - const destination = resolveDestination( - generated.diagram.manifest.id, + const artifact = yield* exportCreatedDiagram( + generated.diagram, input.format, input.destination, ); - const bytes = yield* exporter - .exportArtifact(generated.diagram.manifest.id, input.format) - .pipe( - Effect.mapError((error) => - preserveGeneratedRecordOnExportFailure( - error, - generated.diagram.manifest.id, - input.format, - ), - ), - ); - - yield* Effect.gen(function* () { - if (destination === "-") return; - if (input.destination._tag === "ProjectDiagrams") { - const filesystem = yield* LocalFileSystem; - yield* filesystem.makeDirectory( - join(input.destination.cwd, "diagrams"), - true, - ); - } - yield* writeExportFile(destination, bytes); - }).pipe( - Effect.mapError((error) => - preserveGeneratedRecordOnExportFailure( - error, - generated.diagram.manifest.id, - input.format, - ), - ), - ); return { generated, - artifact: { - id: generated.diagram.manifest.id, - format: input.format, - destination, - sizeBytes: bytes.byteLength, - ...(destination === "-" ? { stdoutBytes: bytes } : {}), - }, + artifact, } satisfies GenerateWorkflowResult; }, ); diff --git a/apps/cli/src/help-brand.ts b/apps/cli/src/help-brand.ts index 372b819c..feef4426 100644 --- a/apps/cli/src/help-brand.ts +++ b/apps/cli/src/help-brand.ts @@ -63,7 +63,10 @@ const TILE_MATERIALS = { dark: { red: 222, green: 152, blue: 158, ansi256: 175 }, light: { red: 222, green: 152, blue: 158, ansi256: 175 }, }, -} as const satisfies Record>; +} as const satisfies Record< + string, + Record +>; const TILE_WIDTH = 16; const LOCKUP_GAP = 2; @@ -308,6 +311,12 @@ export function renderRootHelp(options: HelpBrandOptions): string { ), width, ), + action( + "canvas", + "Build and export a typed Universal CanvasSpec.", + options, + width, + ), "", heading("WORK WITH A DIAGRAM", options), action("show", "Inspect a local diagram.", options, width), diff --git a/apps/cli/src/help.test.ts b/apps/cli/src/help.test.ts index d56c8793..d2bddd3c 100644 --- a/apps/cli/src/help.test.ts +++ b/apps/cli/src/help.test.ts @@ -79,6 +79,7 @@ describe("golden product help", () => { "root", "docs", "generate", + "canvas", "create", "show", "edit", @@ -128,6 +129,7 @@ describe("golden product help", () => { }); expect(output).toContain("Canonical flowchart example"); + expect(output).toContain("Universal CanvasSpec example"); expect(output).toContain("Share/pull safety limits"); expect(output).toContain("stdout contains only artifact bytes"); await expect(output).toMatchFileSnapshot( @@ -249,6 +251,8 @@ describe("golden product help", () => { it("keeps parser-level exclusivity failures in the JSON usage envelope", () => { for (const args of [ ["generate", "--output", "json"], + ["canvas", "--output", "json"], + ["canvas", "--file", "a.json", "--json", "{}", "--output", "json"], ["create", "--output", "json"], ["create", "--file", "a.json", "--json", "{}", "--output", "json"], ["edit", "release-flow", "--output", "json"], diff --git a/apps/cli/src/input.ts b/apps/cli/src/input.ts index 8d38daca..10d258e8 100644 --- a/apps/cli/src/input.ts +++ b/apps/cli/src/input.ts @@ -9,7 +9,11 @@ import { parseJsonPatchInput } from "./patch.js"; export interface InputReadOptions { readonly maxBytes?: number; - readonly content: "JSON document" | "patch request" | "share link"; + readonly content: + | "CanvasSpec document" + | "JSON document" + | "patch request" + | "share link"; } export class InputReader extends Context.Service< diff --git a/apps/cli/src/internal/effect-unstable-cli.ts b/apps/cli/src/internal/effect-unstable-cli.ts index 996766df..08ac8a8a 100644 --- a/apps/cli/src/internal/effect-unstable-cli.ts +++ b/apps/cli/src/internal/effect-unstable-cli.ts @@ -27,6 +27,7 @@ const cliVersion = const PUBLIC_COMMANDS = new Set([ "generate", + "canvas", "docs", "create", "show", diff --git a/apps/cli/src/output.ts b/apps/cli/src/output.ts index 5fd51008..55c25aef 100644 --- a/apps/cli/src/output.ts +++ b/apps/cli/src/output.ts @@ -89,6 +89,13 @@ function failureView(error: CliFailure): ErrorView { hint: error.hint, details: error.details, }; + case "CliCanvasError": + return { + code: error.code, + message: error.message, + hint: error.hint, + details: error.details, + }; case "CliGenerationError": return { code: error.code, diff --git a/apps/cli/src/storage.test.ts b/apps/cli/src/storage.test.ts index c17366b5..fef49c21 100644 --- a/apps/cli/src/storage.test.ts +++ b/apps/cli/src/storage.test.ts @@ -9,8 +9,9 @@ import { } from "node:fs/promises"; import { join, resolve } from "node:path"; +import { CanvasSpec } from "@sketchi/diagram-agent"; import { assert, describe, it } from "@effect/vitest"; -import { Deferred, Effect, Fiber, Layer, Ref } from "effect"; +import { Deferred, Effect, Fiber, Layer, Ref, Schema } from "effect"; import { FastCheck, TestClock } from "effect/testing"; import { type BuiltDiagram } from "./contracts.js"; @@ -126,10 +127,7 @@ function pngWithTrailingIdatBytes(): Uint8Array { const view = new DataView(bytes.buffer); view.setUint32(offset, length + trailingBytes.length); const crcOffset = dataEnd + trailingBytes.length; - view.setUint32( - crcOffset, - crc32(bytes.subarray(typeOffset, crcOffset)), - ); + view.setUint32(crcOffset, crc32(bytes.subarray(typeOffset, crcOffset))); return bytes; } offset += length + 12; @@ -279,6 +277,54 @@ describe("diagram storage", () => { ), ); + it.effect( + "round-trips a Universal Canvas record through the shared store", + () => + withTestRoot((root) => + Effect.gen(function* () { + const store = yield* DiagramStore; + const spec = Schema.decodeUnknownSync(CanvasSpec)({ + kind: "canvas", + version: 1, + diagramId: "stored-canvas", + title: "Stored Canvas", + width: 480, + height: 320, + accentColor: "#2563eb", + backgroundColor: "#ffffff", + elements: [ + { + type: "text", + id: "heading", + x: 40, + y: 40, + text: "Stored Canvas", + fontSize: 28, + }, + ], + layers: [], + layouts: [], + zOrder: ["heading"], + }); + const diagram: BuiltDiagram = { + id: spec.diagramId, + type: "canvas", + title: spec.title, + document: { type: "canvas", spec }, + scene: spec, + excalidraw: builtDiagram().excalidraw, + }; + + const created = yield* store.create(diagram); + const shown = yield* store.show(spec.diagramId); + + assert.strictEqual(created.manifest.type, "canvas"); + assert.strictEqual(shown.document.type, "canvas"); + assert.deepStrictEqual(shown.document, diagram.document); + }).pipe(Effect.provide(storeLayer(root))), + ), + ); + it.effect( "preserves each prior canonical document as a recoverable revision", () => diff --git a/apps/cli/src/storage.ts b/apps/cli/src/storage.ts index b84bd5bb..1d18be8f 100644 --- a/apps/cli/src/storage.ts +++ b/apps/cli/src/storage.ts @@ -37,6 +37,7 @@ import { import { type CanonicalDiagramDocument, decodeCanonicalDiagramDocument, + decodeStoredDiagramDocument, encodeJson, } from "./document.js"; import { @@ -1062,7 +1063,7 @@ const DiagramStoreLive = Layer.effect( diagramId, ), }); - return yield* decodeCanonicalDiagramDocument(parsed).pipe( + return yield* decodeStoredDiagramDocument(parsed).pipe( Effect.mapError(() => storageError( "corrupt_record", @@ -1732,16 +1733,16 @@ const DiagramStoreLive = Layer.effect( return yield* revisionNotFound(diagramId, revision); } if (legacyKind !== "file") return yield* unsafeEntry(legacy, diagramId); - const document = yield* decodeDocument(legacy, diagramId).pipe( - Effect.mapError((error) => - error._tag === "CliStorageError" - ? storageError( - "corrupt_revision", - `Diagram "${diagramId}" revision ${String(revision)} is corrupt.`, - "Choose another revision or repair the legacy revision file.", - diagramId, - ) - : error, + const document = yield* decodeCanonicalDiagramDocument( + yield* readArchivedJson(legacy, diagramId, revision), + ).pipe( + Effect.mapError(() => + storageError( + "corrupt_revision", + `Diagram "${diagramId}" revision ${String(revision)} is corrupt.`, + "Choose another revision or repair the legacy revision file.", + diagramId, + ), ), ); return { diff --git a/docs/canvas-spec.md b/docs/canvas-spec.md index 765d0de7..5ac4d644 100644 --- a/docs/canvas-spec.md +++ b/docs/canvas-spec.md @@ -8,6 +8,20 @@ Agents call `sketchi.createCanvas({ spec, options })` through the existing Code Mode `execute` surface. There is no diagram-specific MCP tool and raw Excalidraw JSON is never accepted as input. +The published CLI exposes the same production contract without requiring MCP: + +```sh +sketchi canvas --file canvas.json --output json +cat canvas.json | sketchi canvas --file - --format excalidraw --dest canvas.excalidraw +``` + +The command defaults to +`https://playground.sketchi.app/api/v1/canvases/create`, validates input and +response with the shared CanvasSpec/CreateCanvas schemas, stores the returned +diagram locally, and exports PNG, Excalidraw, or scene consistently with +`generate`. `SKETCHI_CANVAS_ENDPOINT` and `--endpoint` are intended only for +preview and local testing. + ## Capabilities - Shapes: rectangle, ellipse, diamond, circle, and polygon. diff --git a/tools/project-graph.test.ts b/tools/project-graph.test.ts index 265ccfa4..2a514a22 100644 --- a/tools/project-graph.test.ts +++ b/tools/project-graph.test.ts @@ -114,6 +114,7 @@ const approvedManagedPromiseSiteCounts: Record = { "apps/cli/scripts/bundle-report.mjs": 8, "apps/cli/scripts/package.mjs": 8, "apps/cli/scripts/smoke.mjs": 214, + "apps/cli/src/canvas.ts": 4, "apps/cli/src/filesystem.ts": 69, "apps/cli/src/generate-wizard.ts": 8, "apps/cli/src/generation.ts": 4,