From ab9cd3fd44e833502dce2342ae1e2b2a51847ead Mon Sep 17 00:00:00 2001 From: anandpant <109482096+anandpant@users.noreply.github.com> Date: Fri, 31 Jul 2026 01:35:33 +0000 Subject: [PATCH] feat(cli): add interactive generate wizard (#304) ## Summary - add a Clack-based `sketchi generate` wizard only for human text TTY sessions, with prompt, diagram type, and PNG destination choices - share one typed Effect generation workflow between wizard and direct `--prompt` execution while preserving record-before-export recovery - replace the static root-help treatment with a responsive pencil lockup, progressive disclosure, Unicode/ASCII and terminal color fallbacks - document the human/automation contract, add terminal dependencies and notices, and include a patch changeset ## Contract - `sketchi generate --prompt` remains direct and noninteractive; PNG defaults to `.png` - omitted `--prompt` prompts only when stdin and stdout are TTYs, output is text, and CI is absent - JSON, pipes, redirects, and CI retain the deterministic missing-`--prompt` usage failure - wizard destinations are `/.png`, `/diagrams/.png`, or existing `--dest` semantics; no Git detection occurs ## Proof - `pnpm nx run-many -t typecheck,test,build` - `pnpm nx build-storybook diagram-ui` - `pnpm nx lint sketchi-cli` - `pnpm nx smoke sketchi-cli` - actual pseudo-TTY captures for dark/light/color/ASCII/narrow/non-TTY/cancellation under local `.memory/` - real public generation retry succeeded and produced a visually inspected 1981x1360 PNG --- .changeset/warm-pencils-guide.md | 5 + README.md | 8 + apps/cli/README.md | 20 ++ apps/cli/THIRD_PARTY_NOTICES | 61 ++++ apps/cli/package.json | 7 +- apps/cli/scripts/smoke.mjs | 268 +++++++++++++++++- apps/cli/src/__fixtures__/help/agent-docs.txt | 15 +- apps/cli/src/__fixtures__/help/generate.txt | 12 +- apps/cli/src/__fixtures__/help/root.txt | 39 ++- apps/cli/src/cli.ts | 235 ++++++++++----- apps/cli/src/errors.ts | 12 + apps/cli/src/generate-wizard.test.ts | 96 +++++++ apps/cli/src/generate-wizard.ts | 254 +++++++++++++++++ apps/cli/src/generate-workflow.ts | 110 +++++++ apps/cli/src/help-brand.test.ts | 60 +++- apps/cli/src/help-brand.ts | 199 ++++++++++--- apps/cli/src/help.test.ts | 12 +- apps/cli/src/internal/effect-unstable-cli.ts | 17 ++ apps/cli/src/output.ts | 2 + pnpm-lock.yaml | 92 ++++++ tools/project-graph.test.ts | 3 +- 21 files changed, 1378 insertions(+), 149 deletions(-) create mode 100644 .changeset/warm-pencils-guide.md create mode 100644 apps/cli/src/generate-wizard.test.ts create mode 100644 apps/cli/src/generate-wizard.ts create mode 100644 apps/cli/src/generate-workflow.ts diff --git a/.changeset/warm-pencils-guide.md b/.changeset/warm-pencils-guide.md new file mode 100644 index 00000000..4a2643e4 --- /dev/null +++ b/.changeset/warm-pencils-guide.md @@ -0,0 +1,5 @@ +--- +"@sketchi/cli": patch +--- + +Add a human-TTY-only generate wizard and responsive, terminal-aware CLI presentation while preserving direct and machine-readable generation contracts. diff --git a/README.md b/README.md index 3710847a..7391b4c4 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,17 @@ curl -fsSL https://raw.githubusercontent.com/shpitdev/sketchi/main/install.sh | Turn a prompt into a validated PNG and editable local record: ```sh +sketchi generate + +# Direct and noninteractive: sketchi generate --prompt "Map release approval with pass and revise branches" ``` +Bare `sketchi generate` opens a short wizard only in a human text TTY. Scripts, +pipes, redirects, CI, and JSON output never prompt and must pass `--prompt`. +Explicit `--type` and file `--dest` values preset their wizard questions; +interactive generation remains PNG-only, so other formats and `--dest -` also +require `--prompt`. The default file is `.png` in the current directory. Use `--format excalidraw` or `--format scene` for another artifact, and `--dest` to choose its path or stream bytes with `--dest -`. diff --git a/apps/cli/README.md b/apps/cli/README.md index f29984bb..fd6ad4f9 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -45,9 +45,26 @@ persists the editable result, then writes `.png` in the current directory: ```sh +sketchi generate + +# Direct and noninteractive: sketchi generate --prompt "Map release approval with pass and revise branches" ``` +With no `--prompt`, Sketchi opens a short wizard only when both standard input +and standard output are human terminals, output is text, and CI is absent. It +asks for prompt text, flowchart (the default) or mind map, and where to save the +PNG. The choices are the current directory, a `diagrams/` folder under exactly +the directory where Sketchi was run, or a custom path with the same semantics as +`--dest`. Sketchi does not inspect Git, and it creates `diagrams/` only after +generation succeeds. An explicit `--type` or file `--dest` presets and skips +that wizard question. Explicit `--format png` may proceed; another format or +`--dest -` requires `--prompt` because the interactive wizard writes PNG files. + +Scripts, pipes, redirected streams, CI, and `--output json` never prompt or +block. They must pass `--prompt`; omitting it retains the deterministic usage +error. Passing `--prompt` is always direct and noninteractive. + The PNG is rendered locally from the returned validated artifacts and is not written back into the record. Choose another artifact or destination explicitly: @@ -230,6 +247,9 @@ Agents can use `--output json`, complete noninteractive input through `--json` or `--file`, and raw artifact output through `export --dest -` without prompts. +Agents must pass `--prompt` to `generate`. Interactive prompting is restricted +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. diff --git a/apps/cli/THIRD_PARTY_NOTICES b/apps/cli/THIRD_PARTY_NOTICES index 0d85a5e3..ac741cca 100644 --- a/apps/cli/THIRD_PARTY_NOTICES +++ b/apps/cli/THIRD_PARTY_NOTICES @@ -41,6 +41,67 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +Terminal presentation dependency tree +------------------------------------- + +The CLI bundles the following terminal presentation packages under the MIT +License: + +- @clack/prompts 1.7.0 and @clack/core 1.4.3 +- fast-string-width 3.0.2, fast-wrap-ansi 0.2.2, and sisteransi 1.0.5 +- chalk 6.0.0 +- string-width 8.2.2, get-east-asian-width 1.6.0, and strip-ansi 7.2.0 +- wrap-ansi 10.0.0 and ansi-styles 6.2.3 + +Copyright (c) 2025-Present Bombshell contributors +Copyright (c) 2024-present Fabio Spampinato +Copyright (c) 2025 James Garbutt +Copyright (c) 2018 Terkel Gjervig Nielsen +Copyright (c) Sindre Sorhus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +CI detection dependency tree +---------------------------- + +The CLI bundles is-ci 4.1.0 and ci-info 4.4.0 under the MIT License. + +Copyright (c) Thomas Watson Steen (https://twitter.com/wa7son) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + pako 2.0.3 ----------- diff --git a/apps/cli/package.json b/apps/cli/package.json index 475db5b2..a6b1d12f 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -10,13 +10,18 @@ "dist" ], "dependencies": { + "@clack/prompts": "^1.7.0", "@effect/platform-node": "4.0.0-beta.99", "@excalidraw/excalidraw": "0.18.1", "@resvg/resvg-wasm": "2.6.2", "@sketchi/diagram-agent": "workspace:*", + "chalk": "^6.0.0", "effect": "4.0.0-beta.99", + "is-ci": "^4.1.0", "linkedom": "0.18.13", - "pako": "2.0.3" + "pako": "2.0.3", + "string-width": "^8.2.2", + "wrap-ansi": "^10.0.0" }, "devDependencies": { "@types/pako": "2.0.4", diff --git a/apps/cli/scripts/smoke.mjs b/apps/cli/scripts/smoke.mjs index 43e09919..189834fa 100644 --- a/apps/cli/scripts/smoke.mjs +++ b/apps/cli/scripts/smoke.mjs @@ -58,6 +58,56 @@ function run(file, args, options = {}) { }); } +function shellQuote(value) { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +function runTty(file, args, options = {}) { + return new Promise((complete, reject) => { + const command = [ + "stty cols 100", + [file, ...args].map(shellQuote).join(" "), + ].join("; "); + const child = spawn( + "script", + ["--quiet", "--return", "--command", command, "/dev/null"], + { + cwd: options.cwd ?? workspaceRoot, + env: { ...(options.env ?? process.env), SHELL: "/bin/bash" }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + const stdout = []; + const stderr = []; + child.stdout.on("data", (chunk) => stdout.push(chunk)); + child.stderr.on("data", (chunk) => stderr.push(chunk)); + child.on("error", reject); + child.on("close", (code, signal) => + complete({ + code: code ?? -1, + signal, + stdout: Buffer.concat(stdout), + stderr: Buffer.concat(stderr), + }), + ); + const steps = options.steps ?? []; + const send = (index) => { + if (index >= steps.length) { + child.stdin.end(); + return; + } + setTimeout( + () => { + child.stdin.write(steps[index]); + send(index + 1); + }, + index === 0 ? 400 : 600, + ); + }; + send(0); + }); +} + function parseJson(buffer, label) { try { return JSON.parse(buffer.toString("utf8")); @@ -285,6 +335,7 @@ try { ) && archivedNotices.stdout.includes(Buffer.from("SIL OPEN FONT LICENSE")) && archivedNotices.stdout.includes(Buffer.from("MIT License")) && + archivedNotices.stdout.includes(Buffer.from("is-ci 4.1.0")) && archivedNotices.stdout.includes(Buffer.from("pako 2.0.3")) && archivedNotices.stdout.includes( Buffer.from("Vitaly Puzrin and Andrei Tuputcyn"), @@ -362,6 +413,61 @@ try { "Explicit agent docs omitted the detailed CLI contracts.", ); + const noninteractiveGenerate = await cli(["generate", "--output", "json"]); + expectExit( + noninteractiveGenerate, + 2, + "noninteractive generate without prompt", + ); + assert( + noninteractiveGenerate.stdout.length === 0 && + parseJson( + noninteractiveGenerate.stderr, + "noninteractive generate without prompt", + ).error.code === "usage_error" && + !noninteractiveGenerate.stderr.includes( + Buffer.from("What should Sketchi draw?"), + ), + "Noninteractive generate prompted or changed its machine-readable failure.", + ); + + const ciTtyEnvironment = { ...onlineEnvironment, NO_COLOR: "1" }; + for (const name of [ + "CI", + "GITHUB_ACTIONS", + "BUILD_ID", + "JENKINS_URL", + "TF_BUILD", + "TEAMCITY_VERSION", + ]) { + delete ciTtyEnvironment[name]; + } + for (const provider of [ + { + name: "Jenkins", + environment: { BUILD_ID: "smoke-build", JENKINS_URL: "https://ci.test/" }, + }, + { name: "Azure Pipelines", environment: { TF_BUILD: "True" } }, + { name: "TeamCity", environment: { TEAMCITY_VERSION: "2026.1" } }, + ]) { + const ciTtyGenerate = await runTty(binary, ["generate"], { + cwd: fixtureRoot, + env: { ...ciTtyEnvironment, ...provider.environment }, + }); + expectExit( + ciTtyGenerate, + 2, + `${provider.name} TTY generate without prompt`, + ); + assert( + ciTtyGenerate.stdout.includes(Buffer.from("error: usage_error")) && + !ciTtyGenerate.stdout.includes( + Buffer.from("What should Sketchi draw?"), + ), + `${provider.name} TTY generate prompted instead of returning the usage error.`, + ); + } + const zshCompletions = await cli(["--completions", "zsh"]); expectExit(zshCompletions, 0, "zsh completions"); assert( @@ -418,10 +524,12 @@ try { ); await rm(generatedRecord, { force: true, recursive: true }); - const makeSuccessServer = () => + const makeSuccessServer = (requests = []) => createServer((request, response) => { - request.resume(); + const chunks = []; + request.on("data", (chunk) => chunks.push(chunk)); request.on("end", () => { + requests.push(parseJson(Buffer.concat(chunks), "generate request")); response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ @@ -487,6 +595,159 @@ try { await rm(generatedRecord, { force: true, recursive: true }); await rm(generatedPngPath, { force: true }); + const wizardEnvironment = { + ...onlineEnvironment, + TERM: "xterm-256color", + NO_COLOR: "1", + }; + delete wizardEnvironment.CI; + delete wizardEnvironment.GITHUB_ACTIONS; + const runWizard = (steps, extraArguments = []) => + runTty( + binary, + [ + "generate", + "--endpoint", + `http://127.0.0.1:${String(successPort)}/api/v1/generate`, + ...extraArguments, + ], + { cwd: fixtureRoot, env: wizardEnvironment, steps }, + ); + + const wizardRequests = []; + const wizardServer = makeSuccessServer(wizardRequests); + await new Promise((ready) => + wizardServer.listen(successPort, "127.0.0.1", ready), + ); + try { + const wizardCurrent = await runWizard([ + "Create a release flow.\r", + "\r", + "\r", + ]); + expectExit(wizardCurrent, 0, "wizard current-directory PNG"); + assert( + wizardCurrent.stdout.includes( + Buffer.from("created: generated-release-flow"), + ) && + wizardCurrent.stdout.includes(Buffer.from(generatedPngPath)) && + (await readFile(generatedPngPath)) + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + "Wizard current-directory default did not create and summarize its PNG.", + ); + await rm(generatedRecord, { force: true, recursive: true }); + await rm(generatedPngPath, { force: true }); + + const diagramsDirectory = resolve(fixtureRoot, "diagrams"); + await rm(diagramsDirectory, { force: true, recursive: true }); + const wizardProject = await runWizard([ + "Create a release flow.\r", + "\r", + "\u001b[B\r", + ]); + expectExit(wizardProject, 0, "wizard project-diagrams PNG"); + const projectPng = resolve(diagramsDirectory, "generated-release-flow.png"); + assert( + (await readFile(projectPng)) + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + "Wizard did not create the cwd diagrams folder after generation.", + ); + await rm(generatedRecord, { force: true, recursive: true }); + + const customPng = resolve(fixtureRoot, "wizard-custom.png"); + const wizardCustom = await runWizard([ + "Create a release flow.\r", + "\u001b[B\r", + "\u001b[B\u001b[B\r", + `${customPng}\r`, + ]); + expectExit(wizardCustom, 0, "wizard custom PNG"); + assert( + (await readFile(customPng)) + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + "Wizard custom destination did not reuse --dest semantics.", + ); + assert( + wizardRequests.some((request) => request.type === "mindmap"), + "Wizard mind-map selection did not reach the shared generation workflow.", + ); + await rm(generatedRecord, { force: true, recursive: true }); + + const presetPng = resolve(fixtureRoot, "wizard-preset.png"); + const requestCountBeforePreset = wizardRequests.length; + const wizardPreset = await runWizard( + ["Create a preset release map.\r"], + [ + "--type", + "mindmap", + "--format", + "png", + "--dest", + presetPng, + "--model", + "preset-model", + ], + ); + expectExit(wizardPreset, 0, "wizard explicit presets"); + const presetRequest = wizardRequests[requestCountBeforePreset]; + assert( + presetRequest?.type === "mindmap" && + presetRequest.model === "preset-model" && + !wizardPreset.stdout.includes(Buffer.from("Diagram type")) && + !wizardPreset.stdout.includes(Buffer.from("Save the PNG")) && + (await readFile(presetPng)) + .subarray(0, 8) + .equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])), + "Wizard discarded an explicit type, format, model, or destination preset.", + ); + await rm(generatedRecord, { force: true, recursive: true }); + + for (const invalidPreset of [ + { + name: "non-PNG format", + arguments: ["--format", "excalidraw"], + expected: "pass --prompt for excalidraw or scene generation", + }, + { + name: "stdout destination", + arguments: ["--dest", "-"], + expected: "pass --prompt to write an artifact to stdout", + }, + ]) { + const rejectedPreset = await runWizard([], invalidPreset.arguments); + expectExit(rejectedPreset, 2, `wizard ${invalidPreset.name} rejection`); + assert( + rejectedPreset.stdout.includes(Buffer.from("error: usage_error")) && + rejectedPreset.stdout.includes(Buffer.from(invalidPreset.expected)) && + !rejectedPreset.stdout.includes( + Buffer.from("What should Sketchi draw?"), + ), + `Wizard ${invalidPreset.name} did not fail before prompting.`, + ); + } + + const wizardCancellation = await runTty(binary, ["generate"], { + cwd: fixtureRoot, + env: wizardEnvironment, + steps: ["\u0003"], + }); + expectExit(wizardCancellation, 2, "wizard cancellation"); + assert( + wizardCancellation.stdout.includes(Buffer.from("Generation cancelled")) && + wizardCancellation.stdout.includes(Buffer.from("error: cancelled")) && + !wizardCancellation.stdout.includes(Buffer.from("at file:")) && + !wizardCancellation.stdout.includes(Buffer.from("node:internal")) && + wizardCancellation.stdout.lastIndexOf(Buffer.from("\u001b[?25h")) > + wizardCancellation.stdout.lastIndexOf(Buffer.from("\u001b[?25l")), + "Wizard cancellation was not clean and typed.", + ); + } finally { + await new Promise((closed) => wizardServer.close(closed)); + } + const overrideServer = makeSuccessServer(); await new Promise((ready) => overrideServer.listen(0, "127.0.0.1", ready)); const overridePort = overrideServer.address().port; @@ -1137,6 +1398,7 @@ try { package: archives[0], flows: [ "generate:loopback-default-png-excalidraw-stdout", + "generate:wizard-current-project-custom-mindmap-cancel", "flowchart:create-show-edit-show", "flowchart:restore-offline", "flowchart:patch-edit-blocked-restore-repatch", @@ -1153,6 +1415,8 @@ try { "conflict:6", "generate-network-down:10", "generate-endpoint-error:3", + "generate-missing-prompt-noninteractive:2", + "generate-wizard-cancelled:2", ], network: "offline create/patch/show/edit/list/export/restore; share/pull excluded; generate loopback proof excepted", diff --git a/apps/cli/src/__fixtures__/help/agent-docs.txt b/apps/cli/src/__fixtures__/help/agent-docs.txt index b330a39d..eb75dab4 100644 --- a/apps/cli/src/__fixtures__/help/agent-docs.txt +++ b/apps/cli/src/__fixtures__/help/agent-docs.txt @@ -20,7 +20,7 @@ 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] [--model MODEL] + sketchi generate [--prompt TEXT] [--type flowchart|mindmap] [--model MODEL] 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 @@ -33,9 +33,16 @@ Explicit network commands (one credential-free HTTPS request each): Input and output contracts: create/edit/patch 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 noninteractive text. generate exports PNG to - .png by default; --format scene|excalidraw selects another artifact and --dest - PATH|- overrides its destination. Use --output text|json for result presentation on every command. + --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 + TTYs, output is text, and CI is absent. JSON, pipes, redirected streams, and CI never prompt and + retain the missing --prompt usage error. Explicit --type and file --dest values preset and skip + those wizard questions. The wizard is PNG-only: explicit --format png may proceed; use --prompt + with --format scene|excalidraw or --dest -. It saves to /.png by default, can create + /diagrams/.png after generation succeeds, or accepts a custom file path. + Direct generate exports PNG to .png by default; --format scene|excalidraw selects + another artifact and --dest PATH|- overrides its destination. Use --output text|json for result + presentation on every command. export writes bytes with --dest PATH|- and always writes status to stderr. With --dest -, stdout contains only artifact bytes. A successful PNG file export adds a generic inline-Markdown display hint for calling agents. diff --git a/apps/cli/src/__fixtures__/help/generate.txt b/apps/cli/src/__fixtures__/help/generate.txt index 3b2358a5..d1086919 100644 --- a/apps/cli/src/__fixtures__/help/generate.txt +++ b/apps/cli/src/__fixtures__/help/generate.txt @@ -1,5 +1,13 @@ DESCRIPTION - Create one persisted diagram from --prompt TEXT and export its PNG by default. 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 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. + +Everyday wizard: + The wizard asks only for prompt text, flowchart (default) or mind map, and a PNG destination. + Current directory writes /.png. Project diagrams folder means exactly the + directory where Sketchi was run: /diagrams/.png, with no Git detection; the + diagrams folder is created only after generation succeeds. Explicit --type and file --dest values + preset and skip those questions. Explicit --format png may proceed; pass --prompt for another + format or --dest - because the interactive wizard writes only PNG files. Network and options: Endpoint: https://playground.sketchi.app/api/v1/generate @@ -30,7 +38,7 @@ USAGE FLAGS --output text|json Result presentation format. (choices: text, json) - --prompt TEXT Diagram request text sent to the public Sketchi generate API. + --prompt TEXT Diagram request text sent directly without interactive prompts. --type flowchart|mindmap Requested canonical diagram type; default flowchart. (choices: flowchart, mindmap) --model MODEL Server-routed generation model id; default gemini-3.1-flash-lite. --endpoint URL Unauthenticated generate API URL; defaults to the production Sketchi endpoint. diff --git a/apps/cli/src/__fixtures__/help/root.txt b/apps/cli/src/__fixtures__/help/root.txt index a3c6fa3b..c2509cfc 100644 --- a/apps/cli/src/__fixtures__/help/root.txt +++ b/apps/cli/src/__fixtures__/help/root.txt @@ -1,33 +1,30 @@ - ______ - /#####/ - /#####/ - /#####/ - /#####/ - /#####/ - / # / - /__#/ - /#/ - / + ╱██╲ + ╱████╲ + ╱████╱ + ╱████╱ + ╱████╱ + ╱___╱ + ◢ - sketchi + sketchi describe it. sketchi draws it. START HERE - generate Turn one prompt into a validated PNG and editable local diagram. + generate Start the short wizard, or pass --prompt for direct generation. EXAMPLE sketchi generate --prompt "Map release approval with pass and revise branches" - Writes .png by default. No account or API key needed. + Writes .png in this directory. No account or API key needed. WORK WITH A DIAGRAM - show Inspect a local diagram. - edit Replace its canonical document. - export Write PNG, Excalidraw, or scene bytes. - share Create an encrypted Excalidraw link. + show Inspect a local diagram. + edit Replace its canonical document. + export Write PNG, Excalidraw, or scene bytes. + share Create an encrypted Excalidraw link. GO DEEPER - sketchi docs Complete command map and automation contracts. - sketchi generate --help Every generation option. - sketchi --help Targeted help for any command. + sketchi docs Complete command map and automation contracts. + sketchi generate --help Every generation option. + sketchi --help Targeted help for any command. -Automation: add --output json. Version: sketchi --version +Automation: pass --prompt and add --output json. Version: sketchi --version diff --git a/apps/cli/src/cli.ts b/apps/cli/src/cli.ts index b03ff1f0..2f9e3b3b 100644 --- a/apps/cli/src/cli.ts +++ b/apps/cli/src/cli.ts @@ -14,17 +14,28 @@ import { } from "./contracts.js"; import { encodeJson, validateStorageId } from "./document.js"; import { + type CliFailure, CliFilesystemError, CliShareError, CliValidationError, } from "./errors.js"; import { DiagramExporter, DiagramExporterLive } from "./exporter.js"; import { LocalFileSystem, LocalFileSystemLive } from "./filesystem.js"; +import { + runGenerateWorkflow, + type GenerateWorkflowResult, + type GenerationDestination, +} from "./generate-workflow.js"; +import { + GenerateWizard, + GenerateWizardLive, + liveGenerateWizardAvailable, + type WizardDestination, +} from "./generate-wizard.js"; import { DEFAULT_GENERATE_ENDPOINT, DEFAULT_GENERATION_MODEL, SKETCHI_GENERATE_ENDPOINT_ENV, - generateDiagram, resolveGenerateEndpoint, type GenerateDiagramResult, } from "./generation.js"; @@ -35,6 +46,8 @@ import { cliErrorExitCode, exactlyOnceStringFlag, exclusiveInputSourceFlags, + invalidFlagValue, + missingRequiredFlag, runEffectCommand, } from "./internal/effect-unstable-cli.js"; import { @@ -99,7 +112,7 @@ 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] [--model MODEL] + sketchi generate [--prompt TEXT] [--type flowchart|mindmap] [--model MODEL] 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} @@ -112,9 +125,16 @@ Explicit network commands (one credential-free HTTPS request each): Input and output contracts: create/edit/patch 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 noninteractive text. generate exports PNG to - .png by default; --format scene|excalidraw selects another artifact and --dest - PATH|- overrides its destination. Use --output text|json for result presentation on every command. + --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 + TTYs, output is text, and CI is absent. JSON, pipes, redirected streams, and CI never prompt and + retain the missing --prompt usage error. Explicit --type and file --dest values preset and skip + those wizard questions. The wizard is PNG-only: explicit --format png may proceed; use --prompt + with --format scene|excalidraw or --dest -. It saves to /.png by default, can create + /diagrams/.png after generation succeeds, or accepts a custom file path. + Direct generate exports PNG to .png by default; --format scene|excalidraw selects + another artifact and --dest PATH|- overrides its destination. Use --output text|json for result + presentation on every command. export writes bytes with --dest PATH|- and always writes status to stderr. With --dest -, stdout contains only artifact bytes. A successful PNG file export adds a generic inline-Markdown display hint for calling agents. @@ -266,9 +286,8 @@ function generatedText(result: GenerateDiagramResult): string { ].join("\n"); } -interface GenerateCommandResult { - readonly generated: GenerateDiagramResult; - readonly artifact: ExportResult; +interface GenerateCommandResult extends GenerateWorkflowResult { + readonly interactive: boolean; } function generatedArtifactData(result: GenerateCommandResult) { @@ -279,6 +298,15 @@ function generatedArtifactData(result: GenerateCommandResult) { } function generatedArtifactText(result: GenerateCommandResult): string { + if (result.interactive) { + return [ + `created: ${result.generated.diagram.manifest.id}`, + `artifact: ${result.artifact.destination}`, + "next:", + ` sketchi show ${result.generated.diagram.manifest.id}`, + ` sketchi export ${result.generated.diagram.manifest.id} --format excalidraw --dest ${result.generated.diagram.manifest.id}.excalidraw`, + ].join("\n"); + } const hint = displayHint(result.artifact); return [ generatedText(result.generated), @@ -289,17 +317,44 @@ function generatedArtifactText(result: GenerateCommandResult): string { ].join("\n"); } -function generatedDestination(id: string, format: DiagramFormat): string { - switch (format) { - case "png": - return `${id}.png`; - case "excalidraw": - return `${id}.excalidraw`; - case "scene": - return `${id}.scene.json`; +function wizardDestination( + destination: WizardDestination, +): GenerationDestination { + switch (destination._tag) { + case "CurrentDirectory": + return { _tag: "CurrentDirectory", cwd: process.cwd() }; + case "ProjectDiagrams": + return { _tag: "ProjectDiagrams", cwd: process.cwd() }; + case "Custom": + return destination; } } +function reportGenerateOperation( + output: OutputFormat, + operation: Effect.Effect, +) { + return operation.pipe( + Effect.matchEffect({ + onFailure: (error) => reportFailure("generate", output, error), + onSuccess: (result) => + Effect.gen(function* () { + const writer = yield* OutputWriter; + if (result.artifact.stdoutBytes) { + yield* writer.stdout(result.artifact.stdoutBytes); + } + yield* reportSuccess( + "generate", + output, + generatedArtifactData(result), + generatedArtifactText(result), + result.artifact.destination === "-" ? "stderr" : "stdout", + ); + }), + }), + ); +} + function showText(diagram: StoredDiagram): string { const revisions = revisionLocations(diagram); return [ @@ -334,7 +389,15 @@ function listText(diagrams: ReadonlyArray): string { ].join("\n"); } -const GENERATE_HELP = `Create one persisted diagram from --prompt TEXT and export its PNG by default. 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 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. + +Everyday wizard: + The wizard asks only for prompt text, flowchart (default) or mind map, and a PNG destination. + Current directory writes /.png. Project diagrams folder means exactly the + directory where Sketchi was run: /diagrams/.png, with no Git detection; the + diagrams folder is created only after generation succeeds. Explicit --type and file --dest values + preset and skip those questions. Explicit --format png may proceed; pass --prompt for another + format or --dest - because the interactive wizard writes only PNG files. Network and options: Endpoint: ${DEFAULT_GENERATE_ENDPOINT} @@ -363,18 +426,21 @@ Errors and next steps: const generateCommand = Command.make( "generate", { - prompt: Flag.string("prompt").pipe( - Flag.withDescription( - "Diagram request text sent to the public Sketchi generate API.", + prompt: Flag.optional( + Flag.string("prompt").pipe( + Flag.withDescription( + "Diagram request text sent directly without interactive prompts.", + ), + Flag.withMetavar("TEXT"), ), - Flag.withMetavar("TEXT"), ), - type: Flag.choice("type", ["flowchart", "mindmap"]).pipe( - Flag.withDefault("flowchart"), - Flag.withDescription( - "Requested canonical diagram type; default flowchart.", + type: Flag.optional( + Flag.choice("type", ["flowchart", "mindmap"]).pipe( + Flag.withDescription( + "Requested canonical diagram type; default flowchart.", + ), + Flag.withMetavar("flowchart|mindmap"), ), - Flag.withMetavar("flowchart|mindmap"), ), model: Flag.string("model").pipe( Flag.withDefault(DEFAULT_GENERATION_MODEL), @@ -390,10 +456,13 @@ const generateCommand = Command.make( ), Flag.withMetavar("URL"), ), - format: Flag.choice("format", ["png", "excalidraw", "scene"]).pipe( - Flag.withDefault("png"), - Flag.withDescription("Artifact exported after generation; default png."), - Flag.withMetavar("png|excalidraw|scene"), + format: Flag.optional( + Flag.choice("format", ["png", "excalidraw", "scene"]).pipe( + Flag.withDescription( + "Artifact exported after generation; default png.", + ), + Flag.withMetavar("png|excalidraw|scene"), + ), ), destination: Flag.optional( Flag.string("dest").pipe( @@ -407,52 +476,79 @@ const generateCommand = Command.make( (input) => Effect.gen(function* () { const { output } = yield* rootCommand; - const exporter = yield* DiagramExporter; - const operation = Effect.gen(function* () { - const generated = yield* generateDiagram(input); - const destination = Option.getOrElse(input.destination, () => - generatedDestination(generated.diagram.manifest.id, input.format), - ); - const bytes = yield* exporter.exportArtifact( - generated.diagram.manifest.id, - input.format, - ); - if (destination !== "-") yield* writeExportFile(destination, bytes); - return { - generated, - artifact: { - id: generated.diagram.manifest.id, - format: input.format, - destination, - sizeBytes: bytes.byteLength, - ...(destination === "-" ? { stdoutBytes: bytes } : {}), - }, - }; - }); - yield* operation.pipe( - Effect.matchEffect({ - onFailure: (error) => reportFailure("generate", output, error), - onSuccess: (result) => + const suppliedPrompt = Option.getOrUndefined(input.prompt); + const suppliedType = Option.getOrUndefined(input.type); + const suppliedFormat = Option.getOrUndefined(input.format); + const suppliedDestination = Option.getOrUndefined(input.destination); + if (suppliedPrompt === undefined) { + if (!liveGenerateWizardAvailable(output)) { + return yield* missingRequiredFlag("prompt"); + } + if (suppliedFormat !== undefined && suppliedFormat !== "png") { + return yield* invalidFlagValue( + "format", + suppliedFormat, + "png when --prompt is omitted; pass --prompt for excalidraw or scene generation", + ); + } + if (suppliedDestination === "-") { + return yield* invalidFlagValue( + "dest", + suppliedDestination, + "a file path when --prompt is omitted; pass --prompt to write an artifact to stdout", + ); + } + const wizard = yield* GenerateWizard; + yield* reportGenerateOperation( + output, + Effect.scoped( Effect.gen(function* () { - const writer = yield* OutputWriter; - if (result.artifact.stdoutBytes) { - yield* writer.stdout(result.artifact.stdoutBytes); - } - yield* reportSuccess( - "generate", - output, - generatedArtifactData(result), - generatedArtifactText(result), - result.artifact.destination === "-" ? "stderr" : "stdout", - ); + const answers = yield* wizard.ask({ + ...(suppliedType === undefined ? {} : { type: suppliedType }), + ...(suppliedDestination === undefined + ? {} + : { + destination: { + _tag: "Custom", + path: suppliedDestination, + }, + }), + }); + const activity = yield* wizard.activity; + const result = yield* runGenerateWorkflow({ + endpoint: input.endpoint, + model: input.model, + prompt: answers.prompt, + type: answers.type, + format: "png", + destination: wizardDestination(answers.destination), + }); + yield* activity.succeed("Diagram ready"); + return { ...result, interactive: true }; }), - }), + ), + ); + return; + } + yield* reportGenerateOperation( + output, + runGenerateWorkflow({ + endpoint: input.endpoint, + model: input.model, + prompt: suppliedPrompt, + type: suppliedType ?? "flowchart", + format: suppliedFormat ?? "png", + destination: + suppliedDestination === undefined + ? { _tag: "Default" } + : { _tag: "Custom", path: suppliedDestination }, + }).pipe(Effect.map((result) => ({ ...result, interactive: false }))), ); }), ).pipe( Command.withDescription(GENERATE_HELP), Command.withShortDescription( - "Generate, persist, and export a PNG from one prompt.", + "Create a PNG with the wizard or a direct --prompt.", ), Command.withExamples([ { @@ -1023,4 +1119,5 @@ export const CliApplicationLayer = Layer.mergeAll( LinkOpenerLive, InputReaderLive.pipe(Layer.provide(LocalFileSystemLive)), OutputWriterLive, + GenerateWizardLive, ); diff --git a/apps/cli/src/errors.ts b/apps/cli/src/errors.ts index 3c71f4a1..e85f0143 100644 --- a/apps/cli/src/errors.ts +++ b/apps/cli/src/errors.ts @@ -57,6 +57,15 @@ export class CliGenerationError extends Schema.TaggedErrorClass()( + "CliInteractiveError", + { + code: Schema.Literals(["cancelled", "prompt_failed"]), + message: Schema.String, + hint: Schema.String, + }, +) {} + export class CliStorageError extends Schema.TaggedErrorClass()( "CliStorageError", { @@ -121,6 +130,7 @@ export type CliFailure = | CliValidationError | CliBuildError | CliGenerationError + | CliInteractiveError | CliStorageError | CliExportError | CliShareError; @@ -144,6 +154,8 @@ export function exitCodeForFailure(error: CliFailure): number { case "malformed_output": return 12; } + case "CliInteractiveError": + return error.code === "cancelled" ? 2 : 1; case "CliStorageError": if (error.code === "diagram_not_found") return 5; if (error.code === "revision_not_found") return 5; diff --git a/apps/cli/src/generate-wizard.test.ts b/apps/cli/src/generate-wizard.test.ts new file mode 100644 index 00000000..a0fb1845 --- /dev/null +++ b/apps/cli/src/generate-wizard.test.ts @@ -0,0 +1,96 @@ +import { assert, describe, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { + GenerateWizard, + makeGenerateWizardTestLayer, + shouldLaunchGenerateWizard, + validateCustomDestination, +} from "./generate-wizard.js"; + +describe("interactive generate wizard", () => { + it.each([ + { + name: "redirected stdin", + stdinIsTTY: false, + stdoutIsTTY: true, + output: "text" as const, + continuousIntegration: false, + }, + { + name: "redirected stdout", + stdinIsTTY: true, + stdoutIsTTY: false, + output: "text" as const, + continuousIntegration: false, + }, + { + name: "JSON output", + stdinIsTTY: true, + stdoutIsTTY: true, + output: "json" as const, + continuousIntegration: false, + }, + { + name: "continuous integration", + stdinIsTTY: true, + stdoutIsTTY: true, + output: "text" as const, + continuousIntegration: true, + }, + ])("does not launch for $name", (input) => { + assert.isFalse(shouldLaunchGenerateWizard(input)); + }); + + it("launches only for human text TTYs", () => { + assert.isTrue( + shouldLaunchGenerateWizard({ + stdinIsTTY: true, + stdoutIsTTY: true, + output: "text", + continuousIntegration: false, + }), + ); + }); + + it("requires an interactive custom destination to be a file path", () => { + assert.strictEqual( + validateCustomDestination("-"), + "Interactive generation cannot write PNG bytes to stdout. Enter a file path.", + ); + assert.strictEqual( + validateCustomDestination(" - "), + "Interactive generation cannot write PNG bytes to stdout. Enter a file path.", + ); + assert.strictEqual(validateCustomDestination("./release.png"), undefined); + }); + + const events: Array = []; + it.layer( + makeGenerateWizardTestLayer( + { + prompt: "Map a release", + type: "flowchart", + destination: { _tag: "CurrentDirectory" }, + }, + events, + ), + )("test prompt layer", (it) => { + it.effect("provides answers and scoped progress", () => + Effect.scoped( + Effect.gen(function* () { + const wizard = yield* GenerateWizard; + const answers = yield* wizard.ask({ + type: "mindmap", + destination: { _tag: "Custom", path: "preset.png" }, + }); + const activity = yield* wizard.activity; + yield* activity.succeed("Diagram ready"); + + assert.strictEqual(answers.type, "flowchart"); + assert.deepStrictEqual(events, ["success:Diagram ready"]); + }), + ), + ); + }); +}); diff --git a/apps/cli/src/generate-wizard.ts b/apps/cli/src/generate-wizard.ts new file mode 100644 index 00000000..2628dff0 --- /dev/null +++ b/apps/cli/src/generate-wizard.ts @@ -0,0 +1,254 @@ +import { cancel, intro, isCancel, select, spinner, text } from "@clack/prompts"; +import { Context, Effect, Exit, Layer, type Scope } from "effect"; +import isCI from "is-ci"; + +import type { OutputFormat } from "./contracts.js"; +import { CliInteractiveError } from "./errors.js"; +import type { GenerationType } from "./generation.js"; + +export type WizardDestination = + | { readonly _tag: "CurrentDirectory" } + | { readonly _tag: "ProjectDiagrams" } + | { readonly _tag: "Custom"; readonly path: string }; + +export interface GenerateWizardAnswers { + readonly prompt: string; + readonly type: GenerationType; + readonly destination: WizardDestination; +} + +export interface GenerateWizardPresets { + readonly type?: GenerationType; + readonly destination?: WizardDestination; +} + +export interface GenerationActivity { + readonly succeed: (message: string) => Effect.Effect; + readonly fail: (message: string) => Effect.Effect; +} + +export class GenerateWizard extends Context.Service< + GenerateWizard, + { + readonly ask: ( + presets: GenerateWizardPresets, + ) => Effect.Effect; + readonly activity: Effect.Effect; + } +>()("@sketchi/cli/GenerateWizard") {} + +function cancelled(): CliInteractiveError { + return CliInteractiveError.make({ + code: "cancelled", + message: "Generation was cancelled.", + hint: "Run sketchi generate again when you are ready.", + }); +} + +function promptFailure(): CliInteractiveError { + return CliInteractiveError.make({ + code: "prompt_failed", + message: "Sketchi could not read the interactive prompt.", + hint: "Retry in a terminal, or pass --prompt for noninteractive use.", + }); +} + +function unwrapPrompt( + value: A | symbol, +): Effect.Effect { + if (isCancel(value)) { + return Effect.sync(() => cancel("Generation cancelled.")).pipe( + Effect.andThen(Effect.fail(cancelled())), + ); + } + return Effect.succeed(value); +} + +const askText = Effect.tryPromise({ + try: () => + text({ + message: "What should Sketchi draw?", + placeholder: "Map release approval with pass and revise branches", + validate: (value) => + value !== undefined && value.trim().length > 0 + ? undefined + : "Enter a diagram description.", + }), + catch: promptFailure, +}).pipe(Effect.flatMap(unwrapPrompt)); + +const askType = Effect.tryPromise({ + try: () => + select({ + message: "Diagram type", + initialValue: "flowchart", + options: [ + { + value: "flowchart", + label: "Flowchart", + hint: "best for processes and decisions", + }, + { + value: "mindmap", + label: "Mind map", + hint: "best for ideas and topics", + }, + ], + }), + catch: promptFailure, +}).pipe(Effect.flatMap(unwrapPrompt)); + +type DestinationChoice = "current" | "project" | "custom"; + +const askDestinationChoice = Effect.tryPromise({ + try: () => + select({ + message: "Save the PNG", + initialValue: "current", + options: [ + { + value: "current", + label: "Current directory", + hint: ".png", + }, + { + value: "project", + label: "Project diagrams folder", + hint: "diagrams/.png", + }, + { + value: "custom", + label: "Custom path", + hint: "same semantics as --dest", + }, + ], + }), + catch: promptFailure, +}).pipe(Effect.flatMap(unwrapPrompt)); + +export function validateCustomDestination( + value: string | undefined, +): string | undefined { + if (value === undefined || value.trim().length === 0) { + return "Enter a file path."; + } + if (value.trim() === "-") { + return "Interactive generation cannot write PNG bytes to stdout. Enter a file path."; + } + return undefined; +} + +const askCustomDestination = Effect.tryPromise({ + try: () => + text({ + message: "PNG destination", + placeholder: "./diagram.png", + validate: validateCustomDestination, + }), + catch: promptFailure, +}).pipe(Effect.flatMap(unwrapPrompt)); + +const askWizardDestination = Effect.fn( + "sketchi.cli.generateWizard.destination", +)(function* () { + const destinationChoice = yield* askDestinationChoice; + const destination: WizardDestination = + destinationChoice === "current" + ? { _tag: "CurrentDirectory" } + : destinationChoice === "project" + ? { _tag: "ProjectDiagrams" } + : { _tag: "Custom", path: (yield* askCustomDestination).trim() }; + return destination; +}); + +const ask = Effect.fn("sketchi.cli.generateWizard.ask")(function* ( + presets: GenerateWizardPresets, +) { + yield* Effect.sync(() => intro("Create a diagram with Sketchi")); + const prompt = yield* askText; + const type = presets.type ?? (yield* askType); + const destination = presets.destination ?? (yield* askWizardDestination()); + return { prompt: prompt.trim(), type, destination }; +}); + +const activity = Effect.acquireRelease( + Effect.sync(() => { + const indicator = spinner({ output: process.stdout }); + let finished = false; + indicator.start("Sketchi is drawing"); + return { + succeed: (message: string) => + Effect.sync(() => { + if (finished) return; + finished = true; + indicator.stop(message); + }), + fail: (message: string) => + Effect.sync(() => { + if (finished) return; + finished = true; + indicator.error(message); + }), + finish: (exit: Exit.Exit) => + Effect.sync(() => { + if (finished) return; + finished = true; + if (Exit.isFailure(exit)) indicator.error("Generation stopped"); + else indicator.clear(); + }), + }; + }), + (progress, exit) => progress.finish(exit), +).pipe( + Effect.map((progress) => ({ + succeed: progress.succeed, + fail: progress.fail, + })), +); + +export const GenerateWizardLive = Layer.succeed(GenerateWizard, { + ask, + activity, +}); + +export function makeGenerateWizardTestLayer( + answers: GenerateWizardAnswers, + events: Array = [], +) { + return Layer.succeed(GenerateWizard, { + ask: () => Effect.succeed(answers), + activity: Effect.succeed({ + succeed: (message: string) => + Effect.sync(() => events.push(`success:${message}`)), + fail: (message: string) => + Effect.sync(() => events.push(`failure:${message}`)), + }), + }); +} + +export interface GenerateWizardEnvironment { + readonly stdinIsTTY: boolean; + readonly stdoutIsTTY: boolean; + readonly output: OutputFormat; + readonly continuousIntegration: boolean; +} + +export function shouldLaunchGenerateWizard({ + stdinIsTTY, + stdoutIsTTY, + output, + continuousIntegration, +}: GenerateWizardEnvironment): boolean { + return ( + stdinIsTTY && stdoutIsTTY && output === "text" && !continuousIntegration + ); +} + +export function liveGenerateWizardAvailable(output: OutputFormat): boolean { + return shouldLaunchGenerateWizard({ + stdinIsTTY: process.stdin.isTTY === true, + stdoutIsTTY: process.stdout.isTTY === true, + output, + continuousIntegration: isCI, + }); +} diff --git a/apps/cli/src/generate-workflow.ts b/apps/cli/src/generate-workflow.ts new file mode 100644 index 00000000..2daa73e2 --- /dev/null +++ b/apps/cli/src/generate-workflow.ts @@ -0,0 +1,110 @@ +import { join, resolve } from "node:path"; + +import { Effect } from "effect"; + +import type { DiagramFormat } from "./contracts.js"; +import { DiagramExporter } from "./exporter.js"; +import { + generateDiagram, + type GenerateDiagramInput, + type GenerateDiagramResult, +} from "./generation.js"; +import { LocalFileSystem } from "./filesystem.js"; +import { writeExportFile } from "./storage.js"; + +export type GenerationDestination = + | { readonly _tag: "Default" } + | { readonly _tag: "CurrentDirectory"; readonly cwd: string } + | { readonly _tag: "ProjectDiagrams"; readonly cwd: string } + | { readonly _tag: "Custom"; readonly path: string }; + +export interface GenerateWorkflowInput extends GenerateDiagramInput { + readonly format: DiagramFormat; + readonly destination: GenerationDestination; +} + +export interface GeneratedArtifact { + readonly id: string; + readonly format: DiagramFormat; + readonly destination: string; + readonly sizeBytes: number; + readonly stdoutBytes?: Uint8Array; +} + +export interface GenerateWorkflowResult { + readonly generated: GenerateDiagramResult; + readonly artifact: GeneratedArtifact; +} + +export function generatedDestination( + id: string, + format: DiagramFormat, +): string { + switch (format) { + case "png": + return `${id}.png`; + case "excalidraw": + return `${id}.excalidraw`; + case "scene": + return `${id}.scene.json`; + } +} + +function resolveDestination( + id: string, + format: DiagramFormat, + destination: GenerationDestination, +): string { + switch (destination._tag) { + case "Default": + return generatedDestination(id, format); + case "CurrentDirectory": + return resolve(destination.cwd, generatedDestination(id, "png")); + case "ProjectDiagrams": + return resolve( + destination.cwd, + "diagrams", + generatedDestination(id, "png"), + ); + case "Custom": + return destination.path; + } +} + +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, + input.format, + input.destination, + ); + const bytes = yield* exporter.exportArtifact( + generated.diagram.manifest.id, + input.format, + ); + + if (destination !== "-") { + if (input.destination._tag === "ProjectDiagrams") { + const filesystem = yield* LocalFileSystem; + yield* filesystem.makeDirectory( + join(input.destination.cwd, "diagrams"), + true, + ); + } + yield* writeExportFile(destination, bytes); + } + + return { + generated, + artifact: { + id: generated.diagram.manifest.id, + format: input.format, + destination, + sizeBytes: bytes.byteLength, + ...(destination === "-" ? { stdoutBytes: bytes } : {}), + }, + } satisfies GenerateWorkflowResult; + }, +); diff --git a/apps/cli/src/help-brand.test.ts b/apps/cli/src/help-brand.test.ts index 6260b4dd..63a189a3 100644 --- a/apps/cli/src/help-brand.test.ts +++ b/apps/cli/src/help-brand.test.ts @@ -1,13 +1,17 @@ import { describe, expect, it } from "@effect/vitest"; -import { renderRootHelp, terminalPaletteForBackground } from "./help-brand.js"; +import { + renderRootHelp, + terminalPaletteForBackground, + terminalSupportsUnicode, +} from "./help-brand.js"; describe("CLI help brand", () => { it("renders a readable plain fallback without escape sequences", () => { const output = renderRootHelp({ colors: "none", background: "dark" }); - expect(output).toContain("/#####/\n"); - expect(output).toContain(" /__#/"); + expect(output).toContain("╱████╲"); + expect(output).toContain("◢"); expect(output).toContain("sketchi"); expect(output).toContain("START HERE"); expect(output).toContain("WORK WITH A DIAGRAM"); @@ -22,9 +26,53 @@ describe("CLI help brand", () => { }); expect(dark).toContain("\u001b[38;2;195;154;172m"); - expect(dark).toContain("\u001b[1;38;2;246;241;231msketchi"); - expect(dark).toContain("\u001b[1;38;2;195;154;172mSTART HERE"); - expect(light).toContain("\u001b[1;38;2;26;23;18msketchi"); + expect(dark).toContain("\u001b[38;2;246;241;231m\u001b[1msketchi"); + expect(dark).toContain("\u001b[38;2;195;154;172m\u001b[1mSTART HERE"); + expect(light).toContain("\u001b[38;2;26;23;18m\u001b[1msketchi"); + }); + + it("uses a strict ASCII pencil and wraps descriptions at narrow widths", () => { + const output = renderRootHelp({ + colors: "none", + background: "dark", + unicode: false, + width: 36, + }); + + expect(output).toContain("/####\\"); + expect(output).not.toContain("█"); + expect(output).not.toContain("◢"); + expect(output.split("\n").every((line) => [...line].length <= 36)).toBe( + true, + ); + }); + + it("uses ASCII unless the effective locale explicitly supports UTF-8", () => { + expect(terminalSupportsUnicode({ TERM: "dumb", LANG: "en_US.UTF-8" })).toBe( + false, + ); + expect(terminalSupportsUnicode({ TERM: "xterm", LC_ALL: "C" })).toBe(false); + expect( + terminalSupportsUnicode({ + TERM: "xterm", + LC_ALL: "POSIX", + LANG: "en_US.UTF-8", + }), + ).toBe(false); + expect(terminalSupportsUnicode({ TERM: "xterm" })).toBe(false); + expect( + terminalSupportsUnicode({ TERM: "xterm", LANG: "en_US.ISO-8859-1" }), + ).toBe(false); + expect( + terminalSupportsUnicode({ TERM: "xterm", LANG: "en_US.UTF-8" }), + ).toBe(true); + expect( + terminalSupportsUnicode({ + TERM: "xterm", + LC_ALL: "", + LANG: "C.UTF-8", + }), + ).toBe(true); }); it("selects a readable complete palette for every ANSI background", () => { diff --git a/apps/cli/src/help-brand.ts b/apps/cli/src/help-brand.ts index a5b55ef7..ae50f9aa 100644 --- a/apps/cli/src/help-brand.ts +++ b/apps/cli/src/help-brand.ts @@ -1,14 +1,28 @@ -const PENCIL_LINES = [ - " ______", - " /#####/", - " /#####/", - " /#####/", - " /#####/", - " /#####/", - " / # /", - " /__#/", - " /#/", - " /", +import { Chalk } from "chalk"; +import stringWidth from "string-width"; +import wrapAnsi from "wrap-ansi"; + +// These silhouettes follow the down-left nib and up-right barrel of the +// product icon in apps/web/public/icon.svg. The ASCII version is deliberately +// structural rather than a transliteration of box-drawing characters. +const UNICODE_PENCIL = [ + " ╱██╲", + " ╱████╲", + " ╱████╱", + " ╱████╱", + " ╱████╱", + " ╱___╱", + " ◢", +] as const; + +const ASCII_PENCIL = [ + " /##\\", + " /####\\", + " /####/", + " /####/", + " /####/", + " /___/", + " /_", ] as const; const BRAND = { @@ -57,6 +71,8 @@ const MINIMUM_TEXT_CONTRAST = 4.5; export interface HelpBrandOptions { readonly colors: "none" | "ansi256" | "truecolor"; readonly background: "dark" | "light"; + readonly unicode?: boolean; + readonly width?: number; } function styled( @@ -66,12 +82,12 @@ function styled( emphasis: "plain" | "bold" = "plain", ): string { if (options.colors === "none") return text; - const colorCode = + const chalk = new Chalk({ level: options.colors === "truecolor" ? 3 : 2 }); + const paint = options.colors === "truecolor" - ? `38;2;${String(color.red)};${String(color.green)};${String(color.blue)}` - : `38;5;${String(color.ansi256)}`; - const emphasisCode = emphasis === "bold" ? "1;" : ""; - return `\u001b[${emphasisCode}${colorCode}m${text}\u001b[0m`; + ? chalk.rgb(color.red, color.green, color.blue) + : chalk.ansi256(color.ansi256); + return emphasis === "bold" ? paint.bold(text) : paint(text); } function heading(text: string, options: HelpBrandOptions): string { @@ -87,41 +103,114 @@ function description(text: string, options: HelpBrandOptions): string { } function brandLockup(options: HelpBrandOptions): string { + const pencil = options.unicode === false ? ASCII_PENCIL : UNICODE_PENCIL; + const width = Math.max(32, options.width ?? 80); + const indent = width < 40 ? " " : " "; return [ - ...PENCIL_LINES.map((line) => - styled(line, BRAND[options.background], options), - ), + ...pencil.map((line) => styled(line, BRAND[options.background], options)), "", - ` ${styled("sketchi", FOREGROUND[options.background], options, "bold")}`, - ` ${description("describe it. sketchi draws it.", options)}`, + `${indent}${styled("sketchi", FOREGROUND[options.background], options, "bold")}`, + wrappedLine( + indent, + description("describe it. sketchi draws it.", options), + width, + ), ].join("\n"); } +function wrappedLine( + prefix: string, + text: string, + width: number, + continuationIndent = stringWidth(prefix), +): string { + const available = Math.max(16, width - stringWidth(prefix)); + return `${prefix}${wrapAnsi(text, available, { + hard: true, + trim: true, + }).replaceAll("\n", `\n${" ".repeat(continuationIndent)}`)}`; +} + +function action( + name: string, + text: string, + options: HelpBrandOptions, + width: number, + labelWidth = 8, +): string { + if (width < 52) { + return [ + ` ${command(name, options)}`, + wrappedLine(" ", description(text, options), width), + ].join("\n"); + } + const label = name.padEnd(labelWidth); + const prefix = ` ${command(label, options)} `; + return wrappedLine(prefix, description(text, options), width); +} + export function renderRootHelp(options: HelpBrandOptions): string { + const width = Math.max(32, options.width ?? 80); + const example = + 'sketchi generate --prompt "Map release approval with pass and revise branches"'; return [ brandLockup(options), "", heading("START HERE", options), - ` ${command("generate", options)} ${description("Turn one prompt into a validated PNG and editable local diagram.", options)}`, + action( + "generate", + "Start the short wizard, or pass --prompt for direct generation.", + options, + width, + ), "", heading("EXAMPLE", options), - ` ${command('sketchi generate --prompt "Map release approval with pass and revise branches"', options)}`, - ` ${description("Writes .png by default. No account or API key needed.", options)}`, + wrappedLine(" ", command(example, options), width), + wrappedLine( + " ", + description( + "Writes .png in this directory. No account or API key needed.", + options, + ), + width, + ), "", heading("WORK WITH A DIAGRAM", options), - ` ${command("show", options)} ${description("Inspect a local diagram.", options)}`, - ` ${command("edit", options)} ${description("Replace its canonical document.", options)}`, - ` ${command("export", options)} ${description("Write PNG, Excalidraw, or scene bytes.", options)}`, - ` ${command("share", options)} ${description("Create an encrypted Excalidraw link.", options)}`, + action("show", "Inspect a local diagram.", options, width), + action("edit", "Replace its canonical document.", options, width), + action("export", "Write PNG, Excalidraw, or scene bytes.", options, width), + action("share", "Create an encrypted Excalidraw link.", options, width), "", heading("GO DEEPER", options), - ` ${command("sketchi docs", options)} ${description("Complete command map and automation contracts.", options)}`, - ` ${command("sketchi generate --help", options)} ${description("Every generation option.", options)}`, - ` ${command("sketchi --help", options)} ${description("Targeted help for any command.", options)}`, - "", - description( - "Automation: add --output json. Version: sketchi --version", + action( + "sketchi docs", + "Complete command map and automation contracts.", + options, + width, + 26, + ), + action( + "sketchi generate --help", + "Every generation option.", options, + width, + 26, + ), + action( + "sketchi --help", + "Targeted help for any command.", + options, + width, + 26, + ), + "", + wrappedLine( + "", + description( + "Automation: pass --prompt and add --output json. Version: sketchi --version", + options, + ), + width, ), ].join("\n"); } @@ -214,13 +303,23 @@ function terminalPalette( } export function terminalRootHelp(): string { + const width = + process.stdout.columns === undefined || process.stdout.columns <= 0 + ? 80 + : process.stdout.columns; + const unicode = terminalSupportsUnicode(); if ( process.env["NO_COLOR"] !== undefined || process.env["TERM"] === "dumb" || process.env["TERM"] === "ansi" || process.stdout.isTTY !== true ) { - return renderRootHelp({ colors: "none", background: "dark" }); + return renderRootHelp({ + colors: "none", + background: "dark", + unicode, + width, + }); } const colors = process.stdout.hasColors(2 ** 24) ? "truecolor" @@ -228,14 +327,40 @@ export function terminalRootHelp(): string { ? "ansi256" : "none"; if (colors === "none") { - return renderRootHelp({ colors: "none", background: "dark" }); + return renderRootHelp({ + colors: "none", + background: "dark", + unicode, + width, + }); } const palette = terminalPalette(colors); if (!palette.readable) { - return renderRootHelp({ colors: "none", background: palette.background }); + return renderRootHelp({ + colors: "none", + background: palette.background, + unicode, + width, + }); } return renderRootHelp({ colors, background: palette.background, + unicode, + width, }); } + +export function terminalSupportsUnicode( + environment: NodeJS.ProcessEnv = process.env, +): boolean { + if (environment["TERM"] === "dumb" || environment["TERM"] === "linux") { + return false; + } + const locale = [ + environment["LC_ALL"], + environment["LC_CTYPE"], + environment["LANG"], + ].find((value) => value !== undefined && value.length > 0); + return locale !== undefined && /(?:^|[._-])utf-?8(?:@|$)/iu.test(locale); +} diff --git a/apps/cli/src/help.test.ts b/apps/cli/src/help.test.ts index 07260991..04ebd46b 100644 --- a/apps/cli/src/help.test.ts +++ b/apps/cli/src/help.test.ts @@ -61,8 +61,8 @@ function ttyHelp({ else environment["COLORTERM"] = colorTerminal; const terminalCommand = colorForegroundBackground === undefined - ? `env -u COLORFGBG ${command}` - : command; + ? `stty cols 80; env -u COLORFGBG ${command}` + : `stty cols 80; ${command}`; return spawnSync( "script", @@ -187,10 +187,10 @@ describe("golden product help", () => { it("chooses readable dark and light truecolor wordmarks", () => { const dark = ttyHelp({ colorForegroundBackground: "15;0" }); - expect(dark).toContain("\u001b[1;38;2;246;241;231msketchi"); - expect(dark).toContain("\u001b[1;38;2;195;154;172mSTART HERE"); + expect(dark).toContain("\u001b[38;2;246;241;231m\u001b[1msketchi"); + expect(dark).toContain("\u001b[38;2;195;154;172m\u001b[1mSTART HERE"); expect(ttyHelp({ colorForegroundBackground: "0;15" })).toContain( - "\u001b[1;38;2;26;23;18msketchi", + "\u001b[38;2;26;23;18m\u001b[1msketchi", ); }); @@ -214,7 +214,7 @@ describe("golden product help", () => { colorForegroundBackground: "15;0", colorTerminal: null, }), - ).toContain("\u001b[1;38;5;255msketchi"); + ).toContain("\u001b[38;5;255m\u001b[1msketchi"); }); it.each([undefined, "unknown", "15;"])( diff --git a/apps/cli/src/internal/effect-unstable-cli.ts b/apps/cli/src/internal/effect-unstable-cli.ts index 34a01267..996766df 100644 --- a/apps/cli/src/internal/effect-unstable-cli.ts +++ b/apps/cli/src/internal/effect-unstable-cli.ts @@ -120,6 +120,23 @@ export function exactlyOnceStringFlag( }) as Flag.Flag; } +export function missingRequiredFlag(name: string) { + return new CliError.MissingOption({ option: name }); +} + +export function invalidFlagValue( + name: string, + value: string, + expected: string, +) { + return new CliError.InvalidValue({ + option: name, + value, + expected, + kind: "flag", + }); +} + export function runEffectCommand< const Name extends string, Input, diff --git a/apps/cli/src/output.ts b/apps/cli/src/output.ts index c92278d0..5dfdbf7d 100644 --- a/apps/cli/src/output.ts +++ b/apps/cli/src/output.ts @@ -93,6 +93,8 @@ function failureView(error: CliFailure): ErrorView { hint: error.hint, details: error.details, }; + case "CliInteractiveError": + return { code: error.code, message: error.message, hint: error.hint }; case "CliStorageError": return { code: error.code, message: error.message, hint: error.hint }; case "CliExportError": diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7ba8f29..a26e36cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,9 @@ importers: apps/cli: dependencies: + '@clack/prompts': + specifier: ^1.7.0 + version: 1.7.0 '@effect/platform-node': specifier: 4.0.0-beta.99 version: 4.0.0-beta.99(effect@4.0.0-beta.99)(ioredis@5.11.1) @@ -195,15 +198,27 @@ importers: '@sketchi/diagram-agent': specifier: workspace:* version: link:../../packages/diagram/agent + chalk: + specifier: ^6.0.0 + version: 6.0.0 effect: specifier: 4.0.0-beta.99 version: 4.0.0-beta.99 + is-ci: + specifier: ^4.1.0 + version: 4.1.0 linkedom: specifier: 0.18.13 version: 0.18.13 pako: specifier: 2.0.3 version: 2.0.3 + string-width: + specifier: ^8.2.2 + version: 8.2.2 + wrap-ansi: + specifier: ^10.0.0 + version: 10.0.0 devDependencies: '@types/pako': specifier: 2.0.4 @@ -1395,6 +1410,14 @@ packages: '@chevrotain/utils@11.0.3': resolution: {integrity: sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@cloudflare/codemode@0.4.1': resolution: {integrity: sha512-i73cD7GeEzJVxcjqb4yPDCXXq7/h/Le/Vvz/Z3Xxf5qFiiw9G75fd1+KGWvZ/3/Gk6bgL7eUnnwMhByK3b5EUg==} peerDependencies: @@ -6458,6 +6481,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@6.0.0: + resolution: {integrity: sha512-2uNTXIuTTxk7ciZgAU1BQcgnchcG0xXnrs6jzkQfj9SsRa9M2s5zE8WT96hS6KmG4MzWHSrvH43DF1m4XRkrFg==} + engines: {node: '>=22'} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} @@ -7464,9 +7491,18 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.2: resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fast-xml-builder@1.2.0: resolution: {integrity: sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==} @@ -8026,6 +8062,10 @@ packages: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} + is-ci@4.1.0: + resolution: {integrity: sha512-Ab9bQDQ11lWootZUI5qxgN2ZXwxNI5hTwnsvOc1wyxQ7zQ8OkEDw79mI0+9jI3x432NfwbVRru+3noJfXF6lSQ==} + hasBin: true + is-core-module@2.16.2: resolution: {integrity: sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==} engines: {node: '>= 0.4'} @@ -9965,6 +10005,9 @@ packages: resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} engines: {node: '>=18'} + sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -10095,6 +10138,10 @@ packages: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} @@ -10854,6 +10901,10 @@ packages: '@cloudflare/workers-types': optional: true + wrap-ansi@10.0.0: + resolution: {integrity: sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==} + engines: {node: '>=20'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -12109,6 +12160,18 @@ snapshots: '@chevrotain/utils@11.0.3': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@cloudflare/codemode@0.4.1(@modelcontextprotocol/sdk@1.29.0(@cfworker/json-schema@4.1.1)(zod@4.4.3))(ai@6.0.198(zod@4.4.3))(zod@4.4.3)': dependencies: '@types/json-schema': 7.0.15 @@ -17395,6 +17458,8 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@6.0.0: {} + char-regex@1.0.2: {} character-entities-html4@2.1.0: {} @@ -18495,8 +18560,18 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.2: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fast-xml-builder@1.2.0: dependencies: path-expression-matcher: 1.5.0 @@ -19163,6 +19238,10 @@ snapshots: dependencies: binary-extensions: 2.3.0 + is-ci@4.1.0: + dependencies: + ci-info: 4.4.0 + is-core-module@2.16.2: dependencies: hasown: 2.0.4 @@ -21766,6 +21845,8 @@ snapshots: mrmime: 2.0.1 totalist: 3.0.1 + sisteransi@1.0.5: {} + slash@3.0.0: {} sliced@1.0.1: {} @@ -21921,6 +22002,11 @@ snapshots: get-east-asian-width: 1.6.0 strip-ansi: 7.2.0 + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.2.0 + string_decoder@1.3.0: dependencies: safe-buffer: 5.2.1 @@ -22630,6 +22716,12 @@ snapshots: - bufferutil - utf-8-validate + wrap-ansi@10.0.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 8.2.2 + strip-ansi: 7.2.0 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 diff --git a/tools/project-graph.test.ts b/tools/project-graph.test.ts index a79d8f14..39970908 100644 --- a/tools/project-graph.test.ts +++ b/tools/project-graph.test.ts @@ -112,8 +112,9 @@ const approvedManagedPromiseSiteCounts: Record = { "apps/cli/scripts/build.mjs": 27, "apps/cli/scripts/bundle-report.mjs": 8, "apps/cli/scripts/package.mjs": 8, - "apps/cli/scripts/smoke.mjs": 170, + "apps/cli/scripts/smoke.mjs": 214, "apps/cli/src/filesystem.ts": 69, + "apps/cli/src/generate-wizard.ts": 8, "apps/cli/src/generation.ts": 4, "apps/cli/src/png-renderer-runtime.ts": 30, "apps/cli/src/png-renderer.ts": 8,