From 2f219e4bce1e0f747c5c38801dad44fa1225343e Mon Sep 17 00:00:00 2001 From: ayal Date: Fri, 18 Sep 2026 18:17:32 +0300 Subject: [PATCH 1/7] =?UTF-8?q?feat(cli):=20build=20apps=20with=20the=20ag?= =?UTF-8?q?ent=20from=20the=20terminal=20=E2=80=94=20base44=20builder=20+?= =?UTF-8?q?=20base44=20code?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Non-interactive atoms under `base44 builder`: new (template app, over an existing GitHub repository, or through the Wix route with a signed instance), send, status, stop, model. `base44 sandbox preview` prints the preview URL. Every command supports --json; new and send also --stream-json (one JSON line per stream event, then a result) and --verbose. Interactive `base44 code`: an Ink session where the first prompt in an empty directory creates the app, a linked directory or --app-id opens an existing one. Live turn rendering with tool results shown Claude-Code style (edits as diffs, one-line successes, whole errors, Ctrl+O to unfold, a pulsing line for the running tool), a /model picker, and cards for whatever the agent parks on the user — approvals, clarifying questions, permissions, secrets with masked entry, browser steps — answered through the same route the editor uses. Shared core: create / send / stop / state / branch resolution / conversation polling with event diffing / pending-input parsing, used by both faces. Directory rule matches `base44 create`: an empty cwd is the project, else ./; --path picks explicitly. `code` and `send` refuse code-first projects and Superagents. Credentials (secret values, the Wix signed instance) never reach telemetry: request bodies are not captured for those calls and credential-like option values are redacted from the crash context. Build: the standalone binaries compile through Bun.build so the plugin that stubs Ink's dev-only devtools import applies to them too. Lockfile gains only the Ink/React entries, in registry-default form. Co-Authored-By: Claude Fable 5.1 --- bun.lock | 127 ++- knip.json | 2 +- packages/cli/infra/build-binaries.ts | 40 +- packages/cli/infra/build.ts | 17 +- packages/cli/infra/bundle.ts | 27 + packages/cli/package.json | 6 +- .../cli/src/cli/commands/builder/index.ts | 18 + .../cli/src/cli/commands/builder/model.ts | 63 ++ packages/cli/src/cli/commands/builder/new.ts | 278 ++++++ packages/cli/src/cli/commands/builder/send.ts | 118 +++ .../cli/src/cli/commands/builder/shared.ts | 272 ++++++ .../cli/src/cli/commands/builder/status.ts | 25 + packages/cli/src/cli/commands/builder/stop.ts | 17 + packages/cli/src/cli/commands/code/index.ts | 216 +++++ packages/cli/src/cli/commands/code/logo.ts | 155 ++++ packages/cli/src/cli/commands/code/paste.ts | 93 ++ .../cli/src/cli/commands/code/pending-card.ts | 280 ++++++ packages/cli/src/cli/commands/code/render.ts | 470 ++++++++++ .../src/cli/commands/code/session-engine.ts | 373 ++++++++ .../cli/src/cli/commands/code/session.tsx | 808 ++++++++++++++++++ .../cli/src/cli/commands/sandbox/index.ts | 4 +- .../cli/src/cli/commands/sandbox/preview.ts | 20 + packages/cli/src/cli/program.ts | 6 + .../cli/src/cli/telemetry/commander-hooks.ts | 16 +- .../cli/src/core/clients/base44-client.ts | 10 + packages/cli/src/core/model.ts | 98 +++ packages/cli/src/core/resources/apps/api.ts | 366 ++++++++ .../cli/src/core/resources/apps/pending.ts | 264 ++++++ .../cli/src/core/resources/apps/stream.ts | 370 ++++++++ packages/cli/tests/cli/builder.spec.ts | 545 ++++++++++++ packages/cli/tests/cli/logo.spec.ts | 41 + packages/cli/tests/cli/pending-card.spec.ts | 161 ++++ packages/cli/tests/core/pending.spec.ts | 193 +++++ packages/cli/tests/core/stream.spec.ts | 491 +++++++++++ packages/cli/tsconfig.json | 1 + 35 files changed, 5940 insertions(+), 51 deletions(-) create mode 100644 packages/cli/infra/bundle.ts create mode 100644 packages/cli/src/cli/commands/builder/index.ts create mode 100644 packages/cli/src/cli/commands/builder/model.ts create mode 100644 packages/cli/src/cli/commands/builder/new.ts create mode 100644 packages/cli/src/cli/commands/builder/send.ts create mode 100644 packages/cli/src/cli/commands/builder/shared.ts create mode 100644 packages/cli/src/cli/commands/builder/status.ts create mode 100644 packages/cli/src/cli/commands/builder/stop.ts create mode 100644 packages/cli/src/cli/commands/code/index.ts create mode 100644 packages/cli/src/cli/commands/code/logo.ts create mode 100644 packages/cli/src/cli/commands/code/paste.ts create mode 100644 packages/cli/src/cli/commands/code/pending-card.ts create mode 100644 packages/cli/src/cli/commands/code/render.ts create mode 100644 packages/cli/src/cli/commands/code/session-engine.ts create mode 100644 packages/cli/src/cli/commands/code/session.tsx create mode 100644 packages/cli/src/cli/commands/sandbox/preview.ts create mode 100644 packages/cli/src/core/model.ts create mode 100644 packages/cli/src/core/resources/apps/api.ts create mode 100644 packages/cli/src/core/resources/apps/pending.ts create mode 100644 packages/cli/src/core/resources/apps/stream.ts create mode 100644 packages/cli/tests/cli/builder.spec.ts create mode 100644 packages/cli/tests/cli/logo.spec.ts create mode 100644 packages/cli/tests/cli/pending-card.spec.ts create mode 100644 packages/cli/tests/core/pending.spec.ts create mode 100644 packages/cli/tests/core/stream.spec.ts diff --git a/bun.lock b/bun.lock index a5378fbbb..1cb411bdb 100644 --- a/bun.lock +++ b/bun.lock @@ -11,14 +11,17 @@ }, "packages/cli": { "name": "base44", - "version": "0.1.14", + "version": "0.1.15", "bin": { "base44": "./bin/run.js", }, "dependencies": { "@deno/loader": "https://npm.jsr.io/~/11/@jsr/deno__loader/0.5.0.tgz", "esbuild": "0.28.0", + "ink": "^5", + "ink-text-input": "^6", "miniflare": "4.20260722.0", + "react": "^18", }, "devDependencies": { "@base44-cli/logger": "workspace:*", @@ -37,6 +40,7 @@ "@types/ms": "^2.1.0", "@types/multer": "^2.0.0", "@types/node": "^22.10.5", + "@types/react": "^18", "@vercel/detect-agent": "^1.1.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -102,6 +106,8 @@ }, }, "packages": { + "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.1.3", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-3yWxPTq3UQ/FY9p1ErPxIyfT64elWaMvM9lIHnaqpyft63tkxodF5aUElYHrdisWve5cETkh1+KBw1yJuW0aRw=="], + "@apidevtools/json-schema-ref-parser": ["@apidevtools/json-schema-ref-parser@11.9.3", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0" } }, "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ=="], "@base44-cli/logger": ["@base44-cli/logger@workspace:packages/logger"], @@ -456,10 +462,14 @@ "@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="], + "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], + "@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="], + "@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="], "@types/serve-static": ["@types/serve-static@2.2.0", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*" } }, "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ=="], @@ -484,9 +494,11 @@ "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], - "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], "append-field": ["append-field@1.0.0", "", {}, "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw=="], @@ -498,6 +510,8 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axios": ["axios@1.13.6", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ=="], @@ -540,10 +554,18 @@ "chownr": ["chownr@3.0.0", "", {}, "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g=="], + "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], + + "cli-cursor": ["cli-cursor@4.0.0", "", { "dependencies": { "restore-cursor": "^4.0.0" } }, "sha512-VGtlMu3x/4DOtIUwEkRezxUZ2lBacNJCHash0N0WeZDBS+7Ux1dm3XWAgWYxLJFMMdOeXMHXorshEFhbMSGelg=="], + + "cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "^2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -560,6 +582,8 @@ "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], @@ -568,6 +592,8 @@ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], @@ -594,7 +620,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], @@ -604,6 +630,8 @@ "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -616,12 +644,16 @@ "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "es-toolkit": ["es-toolkit@1.52.0", "", {}, "sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA=="], + "esbuild": ["esbuild@0.28.0", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.0", "@esbuild/android-arm": "0.28.0", "@esbuild/android-arm64": "0.28.0", "@esbuild/android-x64": "0.28.0", "@esbuild/darwin-arm64": "0.28.0", "@esbuild/darwin-x64": "0.28.0", "@esbuild/freebsd-arm64": "0.28.0", "@esbuild/freebsd-x64": "0.28.0", "@esbuild/linux-arm": "0.28.0", "@esbuild/linux-arm64": "0.28.0", "@esbuild/linux-ia32": "0.28.0", "@esbuild/linux-loong64": "0.28.0", "@esbuild/linux-mips64el": "0.28.0", "@esbuild/linux-ppc64": "0.28.0", "@esbuild/linux-riscv64": "0.28.0", "@esbuild/linux-s390x": "0.28.0", "@esbuild/linux-x64": "0.28.0", "@esbuild/netbsd-arm64": "0.28.0", "@esbuild/netbsd-x64": "0.28.0", "@esbuild/openbsd-arm64": "0.28.0", "@esbuild/openbsd-x64": "0.28.0", "@esbuild/openharmony-arm64": "0.28.0", "@esbuild/sunos-x64": "0.28.0", "@esbuild/win32-arm64": "0.28.0", "@esbuild/win32-ia32": "0.28.0", "@esbuild/win32-x64": "0.28.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], @@ -674,6 +706,8 @@ "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], "get-port": ["get-port@7.1.0", "", {}, "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw=="], @@ -714,8 +748,14 @@ "immediate": ["immediate@3.0.6", "", {}, "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ink": ["ink@5.2.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.1.3", "ansi-escapes": "^7.0.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.3.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^4.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.22.0", "indent-string": "^5.0.0", "is-in-ci": "^1.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.29.0", "scheduler": "^0.23.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^7.2.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=18.0.0", "react": ">=18.0.0", "react-devtools-core": "^4.19.1" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-BqcUyWrG9zq5HIwW6JcfFHsIYebJkWWb4fczNah1goUO0vv5vneIlfwuS85twyJ5hYR/y18FlAYUxrO9ChIWVg=="], + + "ink-text-input": ["ink-text-input@6.0.0", "", { "dependencies": { "chalk": "^5.3.0", "type-fest": "^4.18.2" }, "peerDependencies": { "ink": ">=5", "react": ">=18" } }, "sha512-Fw64n7Yha5deb1rHY137zHTAbSTNelUKuB5Kkk2HACXEtwIHBCf9OH2tP/LQ9fRYTl1F0dZgbW0zPnZk6FA9Lw=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], "is-arguments": ["is-arguments@1.2.0", "", { "dependencies": { "call-bound": "^1.0.2", "has-tostringtag": "^1.0.2" } }, "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA=="], @@ -726,12 +766,14 @@ "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="], "is-generator-function": ["is-generator-function@1.1.2", "", { "dependencies": { "call-bound": "^1.0.4", "generator-function": "^2.0.0", "get-proto": "^1.0.1", "has-tostringtag": "^1.0.2", "safe-regex-test": "^1.1.0" } }, "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + "is-in-ci": ["is-in-ci@1.0.0", "", { "bin": { "is-in-ci": "cli.js" } }, "sha512-eUuAjybVTHMYWm/U+vBO1sY/JOCgoPCXRxzdju0K+K0BiGW0SChEL1MLC0PoCIR1OlPo5YAp8HuQoUlsWEICwg=="], + "is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="], "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], @@ -764,6 +806,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "json-schema-to-typescript": ["json-schema-to-typescript@15.0.4", "", { "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.5.5", "@types/json-schema": "^7.0.15", "@types/lodash": "^4.17.7", "is-glob": "^4.0.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "minimist": "^1.2.8", "prettier": "^3.2.5", "tinyglobby": "^0.2.9" }, "bin": { "json2ts": "dist/src/cli.js" } }, "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ=="], @@ -802,6 +846,8 @@ "lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], @@ -818,6 +864,8 @@ "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "miniflare": ["miniflare@4.20260722.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.35.2", "undici": "7.28.0", "workerd": "1.20260722.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-LW6ABMhCx/yIEFBLC/DO4yAhdm2T/G7jp7pr5T2kj895+CCIaHZqpMXdW9O6YE48LcYcCJChwWc8aEs1vpbTXw=="], "minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], @@ -854,6 +902,8 @@ "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], "outdent": ["outdent@0.8.0", "", {}, "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A=="], @@ -872,6 +922,8 @@ "partyserver": ["partyserver@0.0.56", "", { "dependencies": { "nanoid": "^5.0.7" }, "peerDependencies": { "@cloudflare/workers-types": "^4.20240729.0" } }, "sha512-6zdoS/0iBbYatSJe4WtMoCGWDL1I+pGdVlaHdME/TNBv0592Io0AGKWkEQCutHCkIht32AeNdUR66VpsXBaB/w=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="], @@ -906,6 +958,10 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + + "react-reconciler": ["react-reconciler@0.29.2", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-zZQqIiYgDCTP/f1N/mAR10nJGrPD2ZR+jDSEsKWJHYC7Cm2wodlwbR3upZRdC3cjIjSlTLNVyO7Iu0Yy7t2AYg=="], + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], @@ -914,6 +970,8 @@ "requires-port": ["requires-port@1.0.0", "", {}, "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], + "rettime": ["rettime@0.10.1", "", {}, "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -932,6 +990,8 @@ "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], @@ -964,6 +1024,8 @@ "slash": ["slash@5.1.0", "", {}, "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg=="], + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], "socket.io": ["socket.io@4.8.3", "", { "dependencies": { "accepts": "~1.3.4", "base64id": "~2.0.0", "cors": "~2.8.5", "debug": "~4.4.1", "engine.io": "~6.6.0", "socket.io-adapter": "~2.5.2", "socket.io-parser": "~4.2.4" } }, "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A=="], @@ -978,6 +1040,8 @@ "sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="], + "stack-utils": ["stack-utils@2.0.6", "", { "dependencies": { "escape-string-regexp": "^2.0.0" } }, "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -988,7 +1052,7 @@ "strict-event-emitter": ["strict-event-emitter@0.5.1", "", {}, "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ=="], - "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -1028,7 +1092,7 @@ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -1066,9 +1130,11 @@ "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "widest-line": ["widest-line@5.0.0", "", { "dependencies": { "string-width": "^7.0.0" } }, "sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA=="], + "workerd": ["workerd@1.20260722.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260722.1", "@cloudflare/workerd-darwin-arm64": "1.20260722.1", "@cloudflare/workerd-linux-64": "1.20260722.1", "@cloudflare/workerd-linux-arm64": "1.20260722.1", "@cloudflare/workerd-windows-64": "1.20260722.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-NycKuc1x2onvsRfGGpM093vRlLFU2zHDAM0+APpccfg4+gZxDGCH27RmdDvkeBuoZyYqgLo3oAfF6re4mvC3vQ=="], - "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], @@ -1094,6 +1160,8 @@ "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], + "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], + "youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], @@ -1102,10 +1170,15 @@ "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], + "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "base44/@deno/loader": ["@jsr/deno__loader@https://npm.jsr.io/~/11/@jsr/deno__loader/0.5.0.tgz", {}, "sha512-sf/YBwnyAsbyeYYB71Zdj2Ca2Q9tt25EZpAiZdDA9W7Mm3GcpjA2WMeqj19xPIurZK12G3OAsa5yrtPB7E+gvA=="], "base44/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "cli-truncate/slice-ansi": ["slice-ansi@5.0.0", "", { "dependencies": { "ansi-styles": "^6.0.0", "is-fullwidth-code-point": "^4.0.0" } }, "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -1122,6 +1195,8 @@ "front-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="], + "ink/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "json-schema-to-typescript/@types/lodash": ["@types/lodash@4.17.23", "", {}, "sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA=="], "knip/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -1132,6 +1207,8 @@ "msw/path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], + "msw/type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="], + "multer/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], @@ -1140,22 +1217,36 @@ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "restore-cursor/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "sharp/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "socket.io/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], "socket.io-adapter/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], - "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "vite/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "@inquirer/core/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "@inquirer/core/wrap-ansi/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@inquirer/core/wrap-ansi/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "engine.io/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], @@ -1174,8 +1265,6 @@ "socket.io/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], "vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], @@ -1228,12 +1317,24 @@ "vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@inquirer/core/wrap-ansi/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@inquirer/core/wrap-ansi/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "@inquirer/core/wrap-ansi/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "engine.io/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "multer/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "socket.io/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], } } diff --git a/knip.json b/knip.json index 7e0b54385..293cb4e12 100644 --- a/knip.json +++ b/knip.json @@ -11,7 +11,7 @@ "tests/**/testkit/index.ts" ], "project": [ - "src/**/*.ts", + "src/**/*.{ts,tsx}", "tests/**/*.ts" ], "ignore": [ diff --git a/packages/cli/infra/build-binaries.ts b/packages/cli/infra/build-binaries.ts index 6d575d6ac..2baabb0fd 100644 --- a/packages/cli/infra/build-binaries.ts +++ b/packages/cli/infra/build-binaries.ts @@ -5,13 +5,14 @@ * * Steps: * 1. Create dist/assets.tar.gz from dist/assets/ (templates + backend-runtime) - * 2. Cross-compile for each platform with `bun build --compile` + * 2. Cross-compile for each platform with Bun.build({ compile }) * * After this, run `bun run package:binaries` to archive and checksum. */ import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; import chalk from "chalk"; +import { RUNTIME_EXTERNALS, stubReactDevtools } from "./bundle.js"; function collectFiles( dir: string, @@ -84,36 +85,27 @@ for (const { target, output } of TARGETS) { const outPath = join(BINARIES_DIR, output); console.log(chalk.dim(` Compiling ${output}...`)); - const args = [ - "bun", - "build", - "--compile", - `--target=${target}`, - ENTRY, - "--outfile", - outPath, + const result = await Bun.build({ + entrypoints: [ENTRY], // The workerd function runtime cannot ship inside a compiled binary // (native executables and WASM cannot be embedded), so its packages are // excluded here; the runtime probe in function-runtime.ts fails to // import miniflare at runtime and `base44 dev` falls back to Deno. - "--external", - "miniflare", - "--external", - "esbuild", - "--external", - "@deno/loader", - ]; - - // --windows-icon is only supported when the build host is Windows - if (target.includes("windows") && process.platform === "win32") { - args.push(`--windows-icon=${WINDOWS_ICON}`); - } - - const result = Bun.spawnSync(args, { cwd: ROOT }); + external: RUNTIME_EXTERNALS, + plugins: [stubReactDevtools], + compile: { + target, + outfile: outPath, + // A Windows icon can only be applied when the build host is Windows. + ...(target.includes("windows") && process.platform === "win32" + ? { windows: { icon: WINDOWS_ICON } } + : {}), + }, + }); if (!result.success) { console.error(chalk.red(`\n✗ Failed to compile ${output}\n`)); - console.error(result.stderr.toString()); + for (const log of result.logs) console.error(chalk.red(` ${log}`)); process.exit(1); } } diff --git a/packages/cli/infra/build.ts b/packages/cli/infra/build.ts index d28f8542f..eae7d6d81 100644 --- a/packages/cli/infra/build.ts +++ b/packages/cli/infra/build.ts @@ -1,6 +1,7 @@ -import { watch, copyFileSync, mkdirSync } from "node:fs"; +import { copyFileSync, mkdirSync, watch } from "node:fs"; import type { BuildConfig } from "bun"; import chalk from "chalk"; +import { RUNTIME_EXTERNALS, stubReactDevtools } from "./bundle.js"; const runBuild = async (config: BuildConfig) => { const defaultBuildOptions: Partial = { @@ -32,7 +33,10 @@ const copyBackendRuntime = () => { copyFileSync("./backend-runtime/exec.ts", `${outDir}/exec.ts`); // The import map and the module it points at must land next to main.ts — // function-manager.ts resolves the config relative to the wrapper. - copyFileSync("./backend-runtime/import-map.json", `${outDir}/import-map.json`); + copyFileSync( + "./backend-runtime/import-map.json", + `${outDir}/import-map.json`, + ); copyFileSync( "./backend-runtime/base44-runtime.ts", `${outDir}/base44-runtime.ts`, @@ -40,19 +44,12 @@ const copyBackendRuntime = () => { return outDir; }; -// Runtime dependencies of the local workerd function runtime. They cannot be -// bundled (workerd and esbuild ship native binaries; @deno/loader ships WASM), -// so they are real npm `dependencies` resolved from node_modules at runtime — -// the one deliberate exception to the zero-dependency distribution rule. The -// standalone binary excludes them too and `base44 dev` falls back to the Deno -// runtime there. -export const RUNTIME_EXTERNALS = ["miniflare", "esbuild", "@deno/loader"]; - const runAllBuilds = async () => { const cli = await runBuild({ entrypoints: ["./src/cli/index.ts"], outdir: "./dist/cli", external: RUNTIME_EXTERNALS, + plugins: [stubReactDevtools], }); const backendRuntimePath = copyBackendRuntime(); return { diff --git a/packages/cli/infra/bundle.ts b/packages/cli/infra/bundle.ts new file mode 100644 index 000000000..4e81dc955 --- /dev/null +++ b/packages/cli/infra/bundle.ts @@ -0,0 +1,27 @@ +import type { BunPlugin } from "bun"; + +// Runtime dependencies of the local workerd function runtime. They cannot be +// bundled (workerd and esbuild ship native binaries; @deno/loader ships WASM), +// so they are real npm `dependencies` resolved from node_modules at runtime — +// the one deliberate exception to the zero-dependency distribution rule. The +// standalone binary excludes them too and `base44 dev` falls back to the Deno +// runtime there. +export const RUNTIME_EXTERNALS = ["miniflare", "esbuild", "@deno/loader"]; + +// Ink's dev-only react-devtools bridge would otherwise land in the bundle as +// an eager import of a package we don't ship; the code path is dead outside +// DEV=true, so it compiles to an inert stub. Marking it external instead is +// not enough: the compiled binary hoists the import and fails at startup. +export const stubReactDevtools: BunPlugin = { + name: "stub-react-devtools", + setup(build) { + build.onResolve({ filter: /^react-devtools-core$/ }, () => ({ + path: "react-devtools-core-stub", + namespace: "stub", + })); + build.onLoad({ filter: /.*/, namespace: "stub" }, () => ({ + contents: "export default {}; export const connectToDevTools = () => {};", + loader: "js", + })); + }, +}; diff --git a/packages/cli/package.json b/packages/cli/package.json index 2884ff9cd..d452459d1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -55,6 +55,7 @@ "@types/ms": "^2.1.0", "@types/multer": "^2.0.0", "@types/node": "^22.10.5", + "@types/react": "^18", "@vercel/detect-agent": "^1.1.0", "chalk": "^5.6.2", "chokidar": "^5.0.0", @@ -98,6 +99,9 @@ "dependencies": { "@deno/loader": "https://npm.jsr.io/~/11/@jsr/deno__loader/0.5.0.tgz", "esbuild": "0.28.0", - "miniflare": "4.20260722.0" + "ink": "^5", + "ink-text-input": "^6", + "miniflare": "4.20260722.0", + "react": "^18" } } diff --git a/packages/cli/src/cli/commands/builder/index.ts b/packages/cli/src/cli/commands/builder/index.ts new file mode 100644 index 000000000..8e1830cd6 --- /dev/null +++ b/packages/cli/src/cli/commands/builder/index.ts @@ -0,0 +1,18 @@ +import { Command } from "commander"; +import { getModelCommand } from "@/cli/commands/builder/model.js"; +import { getNewCommand } from "@/cli/commands/builder/new.js"; +import { getSendCommand } from "@/cli/commands/builder/send.js"; +import { getStatusCommand } from "@/cli/commands/builder/status.js"; +import { getStopCommand } from "@/cli/commands/builder/stop.js"; + +export function getBuilderCommand(): Command { + return new Command("builder") + .description( + "Build an app with the Base44 builder agent, non-interactively: create it, send turns, read status, stop, pick the model", + ) + .addCommand(getNewCommand()) + .addCommand(getSendCommand()) + .addCommand(getStatusCommand()) + .addCommand(getStopCommand()) + .addCommand(getModelCommand()); +} diff --git a/packages/cli/src/cli/commands/builder/model.ts b/packages/cli/src/cli/commands/builder/model.ts new file mode 100644 index 000000000..18e310851 --- /dev/null +++ b/packages/cli/src/cli/commands/builder/model.ts @@ -0,0 +1,63 @@ +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command, theme } from "@/cli/utils/index.js"; +import { + displayName, + getMe, + MODELS, + resolvePick, + saveBuilderModel, +} from "@/core/model.js"; + +async function modelAction( + { log, jsonMode }: CLIContext, + input: string | undefined, +): Promise { + const me = await getMe(); + const current = me.builder_model ?? null; + + if (!input) { + if (jsonMode) { + return { + stdout: `${JSON.stringify({ + current, + models: MODELS.map((m) => ({ name: m.name, id: m.id })), + })}\n`, + }; + } + for (const m of MODELS) { + const active = (m.id ?? null) === current; + const marker = active ? theme.styles.bold("●") : theme.styles.dim("○"); + const note = m.note ? theme.styles.dim(` (${m.note})`) : ""; + log.message( + `${marker} ${active ? theme.styles.bold(m.name) : m.name}${note}`, + ); + } + return { + outroMessage: `Current: ${theme.styles.bold(displayName(current))}. Set with \`base44 builder model \`.`, + }; + } + + const pick = resolvePick(input); + if ((pick.id ?? null) === current) { + return { outroMessage: `Already on ${theme.styles.bold(pick.name)}.` }; + } + await saveBuilderModel(me.id, pick.id); + if (jsonMode) return { stdout: `${JSON.stringify({ current: pick.id })}\n` }; + return { + outroMessage: + pick.id === null + ? "Model reset — Base44 chooses per app again." + : `Builder model set to ${theme.styles.bold(pick.name)} for every new turn.`, + }; +} + +export function getModelCommand(): Base44Command { + const command = new Base44Command("model", { requireAppContext: false }); + command + .description( + "Pick the builder model for your turns (account-wide). No argument lists models and the current pick; `default` clears it", + ) + .argument("[model]", 'Model name or id, e.g. "Opus 5" or default') + .action(modelAction); + return command; +} diff --git a/packages/cli/src/cli/commands/builder/new.ts b/packages/cli/src/cli/commands/builder/new.ts new file mode 100644 index 000000000..b85743f69 --- /dev/null +++ b/packages/cli/src/cli/commands/builder/new.ts @@ -0,0 +1,278 @@ +import chalk from "chalk"; +import { Option } from "commander"; +import { ndjsonWriter } from "@/cli/commands/builder/send.js"; +import { + createAndLinkApp, + githubReauthLines, + nextStepsLines, +} from "@/cli/commands/builder/shared.js"; +import { + createTurnStream, + formatDuration, +} from "@/cli/commands/code/render.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import type { ImportSourceMode } from "@/core/resources/apps/api.js"; +import { + getAppState, + getPreviewUrl, + resolveActiveBranchId, +} from "@/core/resources/apps/api.js"; +import { streamConversationUntilSettled } from "@/core/resources/apps/stream.js"; + +const POLL_TIMEOUT_MS = 20 * 60_000; +const MODES: ImportSourceMode[] = ["direct", "fork", "copy"]; + +interface NewOptions { + import?: string; + mode?: string; + name?: string; + repoName?: string; + fromBranch?: string; + path?: string; + verbose?: boolean; + streamJson?: boolean; + wixInstance?: string; + wixClientId?: string; +} + +async function readStdin(): Promise { + const chunks: Buffer[] = []; + for await (const chunk of process.stdin) chunks.push(chunk as Buffer); + return Buffer.concat(chunks).toString("utf8"); +} + +async function newAction( + { log, runTask, jsonMode }: CLIContext, + prompt: string | undefined, + options: NewOptions, +): Promise { + if (options.mode && !MODES.includes(options.mode as ImportSourceMode)) { + throw new InvalidInputError("--mode must be direct, fork, or copy."); + } + if ( + (options.mode || options.repoName || options.fromBranch) && + !options.import + ) { + throw new InvalidInputError( + "--mode, --repo-name and --from-branch apply only with --import .", + ); + } + if (options.wixInstance && options.import) { + throw new InvalidInputError("--wix-instance and --import are exclusive."); + } + if (options.wixClientId && !options.wixInstance) { + throw new InvalidInputError( + "--wix-client-id applies only with --wix-instance.", + ); + } + // "-" reads the token from stdin: a signed instance is a credential and does + // not belong in shell history or `ps`. + const signedInstance = options.wixInstance + ? (options.wixInstance === "-" + ? await readStdin() + : options.wixInstance + ).trim() + : undefined; + if (options.wixInstance && !signedInstance) { + throw new InvalidInputError("--wix-instance is empty."); + } + const wixInstance = signedInstance + ? { signedInstance, wixClientId: options.wixClientId?.trim() || undefined } + : undefined; + if (wixInstance && !prompt) { + throw new InvalidInputError( + "--wix-instance needs the prompt argument: what the agent should build.", + ); + } + if (!prompt && !options.import) { + throw new InvalidInputError( + 'Describe the app ("") or pass --import .', + ); + } + if (options.streamJson && jsonMode) { + throw new InvalidInputError("--stream-json and --json are exclusive."); + } + const ndjson = options.streamJson ? ndjsonWriter() : null; + + let app: Awaited>; + try { + app = await runTask( + options.import ? "Importing the repository" : "Creating your app", + () => + createAndLinkApp({ + prompt, + name: options.name, + importRepo: options.import, + mode: options.mode as ImportSourceMode | undefined, + repoName: options.repoName, + fromBranch: options.fromBranch, + path: options.path, + wixInstance, + }), + ); + } catch (error) { + for (const line of (await githubReauthLines(error)) ?? []) + log.message(line); + throw error; + } + + if (ndjson) { + ndjson({ + type: "created", + id: app.id, + repo_url: app.repoUrl ?? null, + editor_url: app.editorUrl, + dir: app.dirName, + path: app.targetDir, + ...(app.clientCreationId + ? { client_creation_id: app.clientCreationId } + : {}), + }); + } else if (!jsonMode) { + if (app.repoUrl) log.message(chalk.dim(`repo ${app.repoUrl}`)); + if (app.clientCreationId) { + log.message( + chalk.dim( + "wix launched — connector connected before the first turn", + ), + ); + } + log.message(chalk.dim(`editor ${app.editorUrl}`)); + log.message( + chalk.dim( + `linked ${app.here ? "./ (this directory)" : `./${app.dirName}`}`, + ), + ); + } + + let finalState: string | undefined; + let previewUrl: string | undefined; + const startedAt = Date.now(); + if (prompt) { + // Completion is the outcome on the turn's user message — the app status + // field flaps mid-turn. + const branchId = await resolveActiveBranchId().catch(() => undefined); + // Live spinner while the sandbox provisions and the first turn starts — + // silent only in --json (stdout must stay pure JSON) or without a TTY. + const stream = createTurnStream( + process.stdout.isTTY === true && !jsonMode && !ndjson, + undefined, + { + idleLabel: "provisioning the sandbox and starting the build", + verbose: options.verbose, + }, + ); + try { + const settled = await streamConversationUntilSettled( + (event) => { + if (ndjson) { + const { kind, ...rest } = event; + ndjson({ type: kind, ...rest }); + } else if (!jsonMode) stream.onEvent(event); + }, + { branchId, timeoutMs: POLL_TIMEOUT_MS }, + ); + finalState = + settled === "timeout" + ? "processing" + : ((await getAppState(app.id)).status?.state ?? "ready"); + if (finalState === "ready") { + previewUrl = await getPreviewUrl().catch(() => undefined); + } + } finally { + stream.stop(); + } + } + + if (ndjson) { + ndjson({ + type: "result", + id: app.id, + preview_url: previewUrl ?? null, + status: finalState ?? "created", + }); + return {}; + } + if (jsonMode) { + return { + stdout: `${JSON.stringify({ + id: app.id, + repo_url: app.repoUrl ?? null, + editor_url: app.editorUrl, + preview_url: previewUrl ?? null, + dir: app.dirName, + path: app.targetDir, + status: finalState ?? "created", + ...(app.clientCreationId + ? { client_creation_id: app.clientCreationId } + : {}), + })}\n`, + }; + } + if (previewUrl) log.message(`preview ${previewUrl}`); + for (const line of nextStepsLines(app)) log.message(line); + if (finalState === "error") { + return { + outroMessage: `The first build reported an error — open the editor for details.`, + }; + } + if (finalState === "processing") { + return { + outroMessage: `Still building — follow it with \`base44 builder status\`.`, + }; + } + return { + outroMessage: prompt + ? `First build finished · ${formatDuration(Date.now() - startedAt)}.` + : "App created.", + }; +} + +export function getNewCommand(): Base44Command { + const command = new Base44Command("new", { requireAppContext: false }); + command + .description( + "Create an app and start building: from a prompt (the Base44 template), or over an existing GitHub repo with --import", + ) + .argument( + "[prompt]", + "What to build; the first agent turn starts immediately", + ) + .option( + "--import ", + "Build over an existing GitHub repository instead of the template", + ) + .option( + "--mode ", + "How to import: direct, fork, or copy (default: direct)", + ) + .option("--name ", "Directory and app name (invented when omitted)") + .option( + "--path ", + "Directory to link (default: the current directory when empty, else ./)", + ) + .option( + "--repo-name ", + "Name for the new GitHub repo when forking/copying", + ) + .option("--from-branch ", "Import a specific branch of the repo") + .addOption( + new Option( + "--wix-instance ", + 'Create through the Wix route with this signed instance (the Wix connector is connected before the first turn); "-" reads the token from stdin', + ).env("BASE44_WIX_INSTANCE"), + ) + .option( + "--wix-client-id ", + "The companion OAuth app's client id, when the Wix launch has one", + ) + .option("--verbose", "Show every tool result in full (no folding)") + .option( + "--stream-json", + "Emit each stream event as a JSON line as it happens, then a final result line", + ) + .action(newAction); + return command; +} diff --git a/packages/cli/src/cli/commands/builder/send.ts b/packages/cli/src/cli/commands/builder/send.ts new file mode 100644 index 000000000..3be0b5711 --- /dev/null +++ b/packages/cli/src/cli/commands/builder/send.ts @@ -0,0 +1,118 @@ +import { + assertBuilderApp, + resolveBranchId, +} from "@/cli/commands/builder/shared.js"; +import { createTurnStream } from "@/cli/commands/code/render.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import type { ChatTurn } from "@/core/resources/apps/api.js"; +import { sendTurn } from "@/core/resources/apps/api.js"; +import { streamConversationDuring } from "@/core/resources/apps/stream.js"; + +function lastAssistantReply(turn: ChatTurn): string | undefined { + const messages = turn.conversation?.messages ?? []; + for (let i = messages.length - 1; i >= 0; i--) { + const { role, content } = messages[i]; + if (role === "assistant" && typeof content === "string" && content.trim()) { + return content.trim(); + } + } + return undefined; +} + +interface SendOptions { + verbose?: boolean; + streamJson?: boolean; +} + +/** One JSON object per line: every stream event as it lands, then `result`. */ +export function ndjsonWriter(): (record: Record) => void { + return (record) => process.stdout.write(`${JSON.stringify(record)}\n`); +} + +async function sendAction( + ctx: CLIContext, + message: string, + options: SendOptions, +): Promise { + if (options.streamJson && ctx.jsonMode) { + throw new InvalidInputError("--stream-json and --json are exclusive."); + } + if (ctx.app) await assertBuilderApp(ctx.app.id); + const branchId = await resolveBranchId(ctx); + + if (options.streamJson) { + const write = ndjsonWriter(); + const turn = await streamConversationDuring( + () => sendTurn(message, branchId), + ({ kind, ...event }) => write({ type: kind, ...event }), + { branchId }, + ); + write({ + type: "result", + queued: turn.queued === true, + status: turn.status?.state ?? "ready", + error_source: turn.status?.error_source ?? null, + reply: lastAssistantReply(turn) ?? null, + }); + return {}; + } + + if (ctx.jsonMode) { + const turn = await ctx.runTask( + "Agent working (a turn can take minutes)", + () => sendTurn(message, branchId), + ); + if (turn.queued) return { stdout: `${JSON.stringify({ queued: true })}\n` }; + return { + stdout: `${JSON.stringify({ + status: turn.status?.state ?? "ready", + error_source: turn.status?.error_source ?? null, + reply: lastAssistantReply(turn) ?? null, + })}\n`, + }; + } + + const stream = createTurnStream(process.stdout.isTTY === true, undefined, { + verbose: options.verbose, + }); + let turn: ChatTurn; + try { + turn = await streamConversationDuring( + () => sendTurn(message, branchId), + stream.onEvent, + { branchId }, + ); + } finally { + stream.stop(); + } + if (turn.queued) { + return { + outroMessage: + "The agent is busy with an earlier message — yours was queued and runs next.", + }; + } + if (turn.status?.state === "error") { + return { + outroMessage: `Turn failed (${turn.status.error_source ?? "unknown"}) — see the editor for details.`, + }; + } + return { outroMessage: "Turn finished." }; +} + +export function getSendCommand(): Base44Command { + const command = new Base44Command("send", { supportsBranch: true }); + command + .description( + "Send the agent one message and stream the turn until it finishes", + ) + .argument("", "What you want the agent to do") + .option("--verbose", "Show every tool result in full (no folding)") + .option( + "--stream-json", + "Emit each stream event as a JSON line as it happens, then a final result line", + ) + .action(sendAction); + return command; +} diff --git a/packages/cli/src/cli/commands/builder/shared.ts b/packages/cli/src/cli/commands/builder/shared.ts new file mode 100644 index 000000000..2f87edfbe --- /dev/null +++ b/packages/cli/src/cli/commands/builder/shared.ts @@ -0,0 +1,272 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { basename, join, relative, resolve } from "node:path"; +import chalk from "chalk"; +import { terminalLink } from "@/cli/commands/code/render.js"; +import type { CLIContext } from "@/cli/types.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { + appConfigExists, + setAppContext, + writeAppConfig, +} from "@/core/project/app-config.js"; +import type { AppState, ImportSourceMode } from "@/core/resources/apps/api.js"; +import { + createApp, + createImportedApp, + createWixLaunchedApp, + getAppState, + isGithubUserTokenError, + resolveActiveBranchId, + startGithubReauth, +} from "@/core/resources/apps/api.js"; +import { isDirEmpty } from "@/core/utils/fs.js"; + +const APP_NAME_RE = /^[A-Za-z0-9._-]+$/; + +const NAME_STOPWORDS = new Set( + "a an the and or of for with to in on that this its it my our your me".split( + " ", + ), +); +const FALLBACK_WORDS = [ + "swift-otter", + "sunny-comet", + "tidy-maple", + "brisk-panda", +]; + +/** base44--<3 chars>: recognizable, unique enough. */ +function inventAppName(prompt?: string): string { + const suffix = Math.random().toString(36).slice(2, 5); + const words = + (prompt ?? "") + .toLowerCase() + .match(/[a-z0-9]+/g) + ?.filter((w) => w.length > 2 && !NAME_STOPWORDS.has(w)) + .slice(0, 3) ?? []; + const core = words.length + ? words.join("-") + : FALLBACK_WORDS[Math.floor(Math.random() * FALLBACK_WORDS.length)]; + return `base44-${core}-${suffix}`.slice(0, 60); +} + +function repoBasename(repoUrl: string): string { + return ( + repoUrl + .replace(/\/+$/, "") + .replace(/\.git$/, "") + .split("/") + .pop() ?? "app" + ); +} + +interface CreateAndLinkOptions { + prompt?: string; + /** Directory + app name. Invented from the prompt (builder) or taken from + * the repo name (import) when omitted. */ + name?: string; + /** Existing GitHub repository to build over. Omit for a template app. */ + importRepo?: string; + mode?: ImportSourceMode; + /** Name for the new GitHub repo when forking/copying. */ + repoName?: string; + fromBranch?: string; + /** Directory to link. Default: the current directory when it is empty + * (same rule as `base44 create`), otherwise ./. */ + path?: string; + /** Create through the Wix route (connector connected before the first turn). */ + wixInstance?: WixInstance; +} + +export interface WixInstance { + /** The signed Wix instance the funnel minted for this site. */ + signedInstance: string; + /** The companion OAuth app's client id, when there is one. */ + wixClientId?: string; +} + +interface LinkedApp { + id: string; + editorUrl: string; + repoUrl?: string; + /** Display path relative to where the command ran; "." when linked here. */ + dirName: string; + targetDir: string; + /** The app was linked into the directory the command ran in. */ + here: boolean; + /** Set by the Wix launch route. */ + clientCreationId?: string; +} + +/** Create the app (template, or over an existing repo) and link a local + * directory to it so every later command resolves the app. */ +export async function createAndLinkApp( + options: CreateAndLinkOptions, +): Promise { + if (options.name && !APP_NAME_RE.test(options.name)) { + throw new InvalidInputError( + "The name becomes a directory — letters, digits, dots, dashes and underscores only.", + ); + } + const cwd = process.cwd(); + const fallbackName = () => + options.importRepo + ? repoBasename(options.importRepo) + : inventAppName(options.prompt); + // An explicit --path, or an empty cwd, is the project directory itself and + // lends the app its name; otherwise the app gets a fresh ./. + const chosenDir = options.path + ? resolve(cwd, options.path) + : (await isDirEmpty(cwd)) + ? cwd + : undefined; + const dirBase = chosenDir ? basename(chosenDir) : undefined; + const name = + options.name ?? + (dirBase && APP_NAME_RE.test(dirBase) ? dirBase : fallbackName()); + const targetDir = chosenDir ?? join(cwd, name); + const here = targetDir === cwd; + const dirName = here ? "." : relative(cwd, targetDir) || name; + await mkdir(targetDir, { recursive: true }); + if (await appConfigExists(targetDir)) { + throw new InvalidInputError( + here + ? "This directory is already linked to a Base44 app. Run `base44 code` here to keep building it, or pass --path for a new one." + : `./${dirName} is already linked to a Base44 app. Pick another name or --path.`, + ); + } + + let clientCreationId: string | undefined; + const created = options.wixInstance + ? await createWixLaunchedApp({ + prompt: options.prompt ?? "", + signedInstance: options.wixInstance.signedInstance, + wixClientId: options.wixInstance.wixClientId, + }).then((c) => { + clientCreationId = c.client_creation_id; + return c; + }) + : options.importRepo + ? await createImportedApp({ + appName: name, + repoUrl: options.importRepo, + sourceMode: options.mode ?? "direct", + newRepoName: options.repoName, + branch: options.fromBranch, + prompt: options.prompt, + }) + : await createApp({ appName: name, prompt: options.prompt }); + + await writeAppConfig(targetDir, created.id); + // Root discovery keys on a PROJECT config, not .app.jsonc. + await mkdir(join(targetDir, "base44"), { recursive: true }); + try { + await writeFile( + join(targetDir, "base44", "config.jsonc"), + `// Base44 project configuration.\n{\n "name": ${JSON.stringify(name)}\n}\n`, + { flag: "wx" }, + ); + } catch { + // Already present. + } + setAppContext({ id: created.id, projectRoot: targetDir }); + + return { + id: created.id, + editorUrl: `${getBase44ApiUrl()}/apps/${created.id}/editor/preview`, + repoUrl: created.imported_repo_url ?? undefined, + dirName, + targetDir, + here, + ...(clientCreationId ? { clientCreationId } : {}), + }; +} + +/** Repository shown the way people say it: no scheme, no trailing .git. */ +export function repoLabel(url: string): string { + return url + .replace(/^https?:\/\//, "") + .replace(/\.git$/, "") + .replace(/\/$/, ""); +} + +/** What kind of app this is, for the session chip. Template apps are "web + * app"; an app built over a repository shows the repository itself. */ +export function appTypeChip(state: AppState): string { + switch (state.app_type ?? "user_app") { + case "imported_app": + return state.imported_repo_url + ? repoLabel(state.imported_repo_url) + : "repository app"; + case "user_game": + return "game"; + case "mobile_app": + return "mobile app"; + case "slide": + return "slides"; + case "user_agent": + return "superagent"; + default: + return "web app"; + } +} + +/** The builder only works on apps it manages. A code-first project (base44 + * create) owns its own source, and a Superagent has no builder conversation; + * sending them a turn would act on a copy nobody sees or fail obscurely. */ +export async function assertBuilderApp(appId: string): Promise { + const state = await getAppState(appId); + if (state.is_managed_source_code === false) { + throw new InvalidInputError( + "This project is code-first (base44 create): you own the source and the builder cannot work on it. Run `base44 builder new` for an agent-built app.", + ); + } + if (state.app_type === "user_agent") { + throw new InvalidInputError( + "This is a Superagent, not an app the builder works on. Superagent has no CLI surface yet.", + ); + } + return state; +} + +/** Lines to show when a create failed because the caller's GitHub OAuth token + * expired (a 401 from api.github.com); null for any other error. A plain + * retry just fails again — the fix is re-authorizing. */ +/** Where the app landed and how to pick it up again — the last thing both + * `app new` and a genesis `code` session print, so the directory is never a + * surprise after the run. */ +export function nextStepsLines(app: LinkedApp): string[] { + const cd = app.here ? "" : `cd ${app.dirName} && `; + return [ + "", + `${chalk.bold("Your app lives in")} ${app.here ? "./ (this directory)" : `./${app.dirName}`}`, + chalk.dim(` ${cd}base44 code # keep building with the agent`), + chalk.dim(` ${cd}base44 builder send "…" # one non-interactive turn`), + chalk.dim( + ` files live remotely — ${cd}base44 sandbox ls to look, base44 eject for a copy`, + ), + ]; +} + +export async function githubReauthLines( + error: unknown, +): Promise { + if (!isGithubUserTokenError(error)) return null; + const link = await startGithubReauth().catch(() => null); + return [ + "Your GitHub authorization expired. Reconnect, then run this again:", + link + ? terminalLink("Reconnect GitHub", link) + : "Open Base44 → GitHub settings to reconnect your account.", + ]; +} + +/** Explicit --branch wins; otherwise the app's single active branch (an + * import's setup branch). Undefined targets main, which is right for a + * template app. */ +export async function resolveBranchId( + ctx: CLIContext, +): Promise { + return ctx.branchId ?? (await resolveActiveBranchId().catch(() => undefined)); +} diff --git a/packages/cli/src/cli/commands/builder/status.ts b/packages/cli/src/cli/commands/builder/status.ts new file mode 100644 index 000000000..b07500bed --- /dev/null +++ b/packages/cli/src/cli/commands/builder/status.ts @@ -0,0 +1,25 @@ +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { getAppState } from "@/core/resources/apps/api.js"; + +async function statusAction(ctx: CLIContext): Promise { + const id = ctx.app?.id as string; + const app = await ctx.runTask("Reading app status", () => getAppState(id)); + const state = app.status?.state ?? "ready"; + if (ctx.jsonMode) { + return { + stdout: `${JSON.stringify({ id: app.id, state, message: app.status?.message ?? null })}\n`, + }; + } + ctx.log.message(`State: ${state}`); + if (app.status?.message) ctx.log.message(`Note: ${app.status.message}`); + return { outroMessage: "Status read." }; +} + +export function getStatusCommand(): Base44Command { + const command = new Base44Command("status"); + command + .description("Show whether the app is building, ready, or errored") + .action(statusAction); + return command; +} diff --git a/packages/cli/src/cli/commands/builder/stop.ts b/packages/cli/src/cli/commands/builder/stop.ts new file mode 100644 index 000000000..532c9536e --- /dev/null +++ b/packages/cli/src/cli/commands/builder/stop.ts @@ -0,0 +1,17 @@ +import { resolveBranchId } from "@/cli/commands/builder/shared.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { stopTurn } from "@/core/resources/apps/api.js"; + +async function stopAction(ctx: CLIContext): Promise { + const branchId = await resolveBranchId(ctx); + await ctx.runTask("Stopping the running turn", () => stopTurn(branchId)); + if (ctx.jsonMode) return { stdout: `${JSON.stringify({ stopped: true })}\n` }; + return { outroMessage: "Stopped." }; +} + +export function getStopCommand(): Base44Command { + const command = new Base44Command("stop", { supportsBranch: true }); + command.description("Stop the agent's running turn").action(stopAction); + return command; +} diff --git a/packages/cli/src/cli/commands/code/index.ts b/packages/cli/src/cli/commands/code/index.ts new file mode 100644 index 000000000..7ed549265 --- /dev/null +++ b/packages/cli/src/cli/commands/code/index.ts @@ -0,0 +1,216 @@ +import chalk from "chalk"; +import { + appTypeChip, + assertBuilderApp, + createAndLinkApp, + githubReauthLines, + nextStepsLines, + repoLabel, + type WixInstance, +} from "@/cli/commands/builder/shared.js"; +import { terminalLink } from "@/cli/commands/code/render.js"; +import { + runGenesisSession, + runInteractiveSession, +} from "@/cli/commands/code/session.js"; +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { type AppIdOptions, Base44Command } from "@/cli/utils/index.js"; +import { InvalidInputError } from "@/core/errors.js"; +import { getAppContext, initAppContext } from "@/core/project/app-config.js"; +import { + getPreviewUrl, + resolveActiveBranchId, +} from "@/core/resources/apps/api.js"; + +const BRAND_ORANGE = "#E86B3C"; + +type LinkedApp = Awaited>; + +interface CodeOptions { + import?: string; + path?: string; + wixInstance?: string; + wixClientId?: string; +} + +/** Turn the session's first prompt into an app and hand back the engine + * wiring. Same create + link as `base44 builder new`. */ +async function bootstrapApp( + prompt: string, + footer: string[], + emit: (line: string) => void, + onCreated: (app: LinkedApp) => void, + importRepo?: string, + path?: string, + wixInstance?: WixInstance, +) { + let app: LinkedApp; + try { + app = await createAndLinkApp({ prompt, importRepo, path, wixInstance }); + } catch (error) { + for (const line of (await githubReauthLines(error)) ?? []) emit(line); + throw error; + } + onCreated(app); + // The directory stays visible for the whole session, next to the links. + footer.push(chalk.dim(`dir ${app.here ? "./" : `./${app.dirName}`}`)); + if (app.repoUrl) footer.push(terminalLink("repo", app.repoUrl)); + footer.push(terminalLink("editor", app.editorUrl)); + emit( + chalk.dim( + app.here + ? "linked ./ (this directory)" + : `linked ./${app.dirName} (cd ${app.dirName} after the session)`, + ), + ); + + const branchId = await resolveActiveBranchId().catch(() => undefined); + let previewPushed = false; + return { + branchId, + awaitingTurnLabel: "provisioning the sandbox and starting the build", + onTurnSettled: async ({ + turnIndex, + ok, + }: { + turnIndex: number; + ok: boolean; + }) => { + if (turnIndex !== 0 || !ok || previewPushed) return; + const url = await getPreviewUrl().catch(() => undefined); + if (url) { + previewPushed = true; + footer.push(terminalLink("preview", url)); + } + }, + }; +} + +async function codeAction( + { log }: CLIContext, + options: CodeOptions, + appId?: string, +): Promise { + const orange = chalk.hex(BRAND_ORANGE); + const chip = (label: string) => chalk.dim(`${orange("●")} ${label}`); + if (process.stdout.isTTY !== true) { + throw new InvalidInputError( + "base44 code is an interactive session and needs a terminal.", + ); + } + + // Inside a linked app, open its session; anywhere else the first prompt + // creates the app. Must be the real context lookup — an existence glob is + // recursive and would match apps in subdirectories of an unlinked cwd. + // --app-id opens that app from anywhere; otherwise a linked directory opens + // its app, and anywhere else the first prompt creates one. + let linked = false; + try { + await initAppContext(appId ? { appId } : {}); + linked = true; + } catch { + // Not linked — genesis below. + } + if (linked) { + if (options.import || options.path || options.wixInstance) { + throw new InvalidInputError( + "--import, --path and --wix-instance create a new app; run them outside a linked project, without --app-id.", + ); + } + const { id, projectRoot } = getAppContext(); + const state = await assertBuilderApp(id); + const branchId = await resolveActiveBranchId().catch(() => undefined); + await runInteractiveSession({ + branchId, + footer: [chip(appTypeChip(state))], + primeFirstPoll: true, + idleHint: "what should the agent do next?", + }); + if (projectRoot) { + log.message(chalk.dim(`app dir ${projectRoot}`)); + return { + outroMessage: "Session closed. Run `base44 code` here to resume.", + }; + } + return { + outroMessage: `Session closed. Resume with \`base44 code --app-id ${id}\`.`, + }; + } + + if (options.wixInstance && options.import) { + throw new InvalidInputError("--wix-instance and --import are exclusive."); + } + const wixInstance: WixInstance | undefined = options.wixInstance?.trim() + ? { + signedInstance: options.wixInstance.trim(), + wixClientId: options.wixClientId?.trim() || undefined, + } + : undefined; + let created: LinkedApp | undefined; + const footer = [ + chip( + options.import + ? repoLabel(options.import) + : wixInstance + ? "web app · wix" + : "web app", + ), + ]; + await runGenesisSession({ + idleHint: options.import + ? "describe what to build over the repository" + : "describe the app you want to build · have one already? base44 code --app-id , or base44 link", + creatingLabel: options.import + ? "importing the repository" + : "creating your app", + modeLabel: options.import + ? `Repository — ${repoLabel(options.import)}` + : wixInstance + ? "Web app — Wix launch (connector connected first)" + : "Web app — Base44 template + builder agent", + footer, + createApp: (prompt, emit) => + bootstrapApp( + prompt, + footer, + emit, + (app) => { + created = app; + }, + options.import, + options.path, + wixInstance, + ), + }); + if (!created) { + return { outroMessage: "Session closed. No app was created." }; + } + for (const line of nextStepsLines(created)) log.message(line); + log.message(chalk.dim(` editor ${created.editorUrl}`)); + return { outroMessage: "Session closed." }; +} + +export function getCodeCommand(): Base44Command { + const command = new Base44Command("code", { requireAppContext: false }); + command + .description( + "Open Base44 Code, an interactive builder session. In a linked directory (or with --app-id ) it opens that app; anywhere else your first prompt creates one (--import to build over your own repository). Attach a directory to an existing app with base44 link.", + ) + .option( + "--import ", + "Build over an existing GitHub repository instead of the Base44 template", + ) + .option( + "--path ", + "Directory to link the new app to (default: the current directory when empty, else ./)", + ) + .option( + "--wix-instance ", + "Create the new app through the Wix route with this signed instance (your first prompt is what the agent runs)", + ) + .option("--wix-client-id ", "The companion OAuth app's client id") + .action((ctx: CLIContext, options: CodeOptions) => + codeAction(ctx, options, command.optsWithGlobals().appId), + ); + return command; +} diff --git a/packages/cli/src/cli/commands/code/logo.ts b/packages/cli/src/cli/commands/code/logo.ts new file mode 100644 index 000000000..1549f3d6a --- /dev/null +++ b/packages/cli/src/cli/commands/code/logo.ts @@ -0,0 +1,155 @@ +import chalk from "chalk"; + +/** + * The Base44 mark, rendered the way `circle.py -d 6 --gap 3.5r --gap-height=0.8r` + * draws it: an anti-aliased disc built from sub-cell block glyphs, corrected + * for ~2:1 terminal cells, with one thin slot cut across it. Terminals known to + * rasterise the Unicode 16 octants get the 2x4 grid; everything else gets the + * 2x2 quadrant blocks every font has. Fully covered cells are painted as + * background rather than a block glyph, so fonts whose blocks stop short of the + * line height don't stripe the fill. + */ +const ROWS = 6; +const ASPECT = 0.44; // cell width / cell height +const SAMPLES = 4; // supersamples per axis on edge pixels +const FILL = 0.6; // coverage that turns a pixel on +// `--gap 3.5r` names row 3.5 and aims at its middle; `--gap-height 0.8r`. +const GAP_ROW = 3.5 + 0.5; +const GAP_HEIGHT_ROWS = 0.8; +export const LOGO_COLS = Math.max(2, Math.floor(ROWS / ASPECT + 0.5)); + +interface Tier { + rx: number; + ry: number; + /** Sub-cell mask → glyph; bit i = row i / rx, column i % rx. */ + table: string[]; +} + +// The octant tier as circle.py fits it: the sixteen "quarter" glyphs are not +// in the tier, so their masks are replaced by the nearest drawable shape, +// preferring to drop a pixel over adding one. +const OCTANT: Tier = { + rx: 2, + ry: 4, + table: Array.from( + " \u{1FB82}\u{1CD00}\u{2598}\u{1CD01}\u{1CD02}\u{1CD03}\u{1CD04}\u{259D}\u{1CD05}\u{1CD06}\u{1CD07}\u{1CD08}\u{2580}\u{1CD09}\u{1CD0A}\u{1CD0B}\u{1CD0C}\u{1CD00}\u{1CD0D}\u{1CD0E}\u{1CD0F}\u{1CD10}\u{1CD11}\u{1CD12}\u{1CD13}\u{1CD14}\u{1CD15}\u{1CD16}\u{1CD17}\u{1CD18}\u{1CD19}\u{1CD1A}\u{1CD1B}\u{1CD1C}\u{1CD1D}\u{1CD1E}\u{1CD1F}\u{1CD03}\u{1CD20}\u{1CD21}\u{1CD22}\u{1CD23}\u{1CD24}\u{1CD25}\u{1CD26}\u{1CD27}\u{1CD28}\u{1CD29}\u{1CD2A}\u{1CD2B}\u{1CD2C}\u{1CD2D}\u{1CD2E}\u{1CD2F}\u{1CD30}\u{1CD31}\u{1CD32}\u{1CD33}\u{1CD34}\u{1CD35}\u{1FB85} \u{1CD36}\u{1CD37}\u{1CD38}\u{1CD39}\u{1CD3A}\u{1CD3B}\u{1CD3C}\u{1CD3D}\u{1CD3E}\u{1CD3F}\u{1CD40}\u{1CD41}\u{1CD42}\u{1CD43}\u{1CD44}\u{2596}\u{1CD45}\u{1CD46}\u{1CD47}\u{1CD48}\u{258C}\u{1CD49}\u{1CD4A}\u{1CD4B}\u{1CD4C}\u{259E}\u{1CD4D}\u{1CD4E}\u{1CD4F}\u{1CD50}\u{259B}\u{1CD51}\u{1CD52}\u{1CD53}\u{1CD54}\u{1CD55}\u{1CD56}\u{1CD57}\u{1CD58}\u{1CD59}\u{1CD5A}\u{1CD5B}\u{1CD5C}\u{1CD5D}\u{1CD5E}\u{1CD5F}\u{1CD60}\u{1CD61}\u{1CD62}\u{1CD63}\u{1CD64}\u{1CD65}\u{1CD66}\u{1CD67}\u{1CD68}\u{1CD69}\u{1CD6A}\u{1CD6B}\u{1CD6C}\u{1CD6D}\u{1CD6E}\u{1CD6F}\u{1CD70} \u{1CD71}\u{1CD72}\u{1CD73}\u{1CD74}\u{1CD75}\u{1CD76}\u{1CD77}\u{1CD78}\u{1CD79}\u{1CD7A}\u{1CD7B}\u{1CD7C}\u{1CD7D}\u{1CD7E}\u{1CD7F}\u{1CD80}\u{1CD81}\u{1CD82}\u{1CD83}\u{1CD84}\u{1CD85}\u{1CD86}\u{1CD87}\u{1CD88}\u{1CD89}\u{1CD8A}\u{1CD8B}\u{1CD8C}\u{1CD8D}\u{1CD8E}\u{1CD8F}\u{2597}\u{1CD90}\u{1CD91}\u{1CD92}\u{1CD93}\u{259A}\u{1CD94}\u{1CD95}\u{1CD96}\u{1CD97}\u{2590}\u{1CD98}\u{1CD99}\u{1CD9A}\u{1CD9B}\u{259C}\u{1CD9C}\u{1CD9D}\u{1CD9E}\u{1CD9F}\u{1CDA0}\u{1CDA1}\u{1CDA2}\u{1CDA3}\u{1CDA4}\u{1CDA5}\u{1CDA6}\u{1CDA7}\u{1CDA8}\u{1CDA9}\u{1CDAA}\u{1CDAB}\u{2582}\u{1CDAC}\u{1CDAD}\u{1CDAE}\u{1CDAF}\u{1CDB0}\u{1CDB1}\u{1CDB2}\u{1CDB3}\u{1CDB4}\u{1CDB5}\u{1CDB6}\u{1CDB7}\u{1CDB8}\u{1CDB9}\u{1CDBA}\u{1CDBB}\u{1CDBC}\u{1CDBD}\u{1CDBE}\u{1CDBF}\u{1CDC0}\u{1CDC1}\u{1CDC2}\u{1CDC3}\u{1CDC4}\u{1CDC5}\u{1CDC6}\u{1CDC7}\u{1CDC8}\u{1CDC9}\u{1CDCA}\u{1CDCB}\u{1CDCC}\u{1CDCD}\u{1CDCE}\u{1CDCF}\u{1CDD0}\u{1CDD1}\u{1CDD2}\u{1CDD3}\u{1CDD4}\u{1CDD5}\u{1CDD6}\u{1CDD7}\u{1CDD8}\u{1CDD9}\u{1CDDA}\u{2584}\u{1CDDB}\u{1CDDC}\u{1CDDD}\u{1CDDE}\u{2599}\u{1CDDF}\u{1CDE0}\u{1CDE1}\u{1CDE2}\u{259F}\u{1CDE3}\u{2586}\u{1CDE4}\u{1CDE5}\u{2588}", + ), +}; +const QUAD: Tier = { + rx: 2, + ry: 2, + table: Array.from( + " \u{2598}\u{259D}\u{2580}\u{2596}\u{258C}\u{259E}\u{259B}\u{2597}\u{259A}\u{2590}\u{259C}\u{2584}\u{2599}\u{259F}\u{2588}", + ), +}; + +type LogoTier = "octant" | "quad"; + +/** Same rule as circle.py: only terminals that draw octants themselves. */ +function detectTier(env: NodeJS.ProcessEnv = process.env): LogoTier { + const term = env.TERM ?? ""; + const prog = env.TERM_PROGRAM ?? ""; + if (prog === "ghostty" || term.includes("ghostty")) return "octant"; + if (env.KITTY_WINDOW_ID || term.includes("kitty")) return "octant"; + if (env.WEZTERM_PANE || env.WEZTERM_EXECUTABLE) return "octant"; + if (term.startsWith("foot") || prog === "contour") return "octant"; + return "quad"; +} + +/** Coverage in [0,1] per pixel. Pixels wholly inside or outside are settled by + * two corner tests; only the outline is supersampled. */ +function coverageGrid( + rows: number, + cols: number, + aspect: number, + pixelH: number, +): number[][] { + const worldH = rows * pixelH; + const worldW = cols * aspect; + const radius = Math.min(worldH, worldW) / 2; + const cx = worldW / 2; + const cy = worldH / 2; + const step = 1 / SAMPLES; + const grid: number[][] = []; + for (let py = 0; py < rows; py++) { + const y0 = py * pixelH; + const y1 = y0 + pixelH; + const dyLo = + y0 <= cy && cy <= y1 ? 0 : Math.min(Math.abs(y0 - cy), Math.abs(y1 - cy)); + const dyHi = Math.max(Math.abs(y0 - cy), Math.abs(y1 - cy)); + const line: number[] = []; + for (let px = 0; px < cols; px++) { + const x0 = px * aspect; + const x1 = x0 + aspect; + const dxLo = + x0 <= cx && cx <= x1 + ? 0 + : Math.min(Math.abs(x0 - cx), Math.abs(x1 - cx)); + const dxHi = Math.max(Math.abs(x0 - cx), Math.abs(x1 - cx)); + if (Math.hypot(dxLo, dyLo) >= radius) { + line.push(0); + continue; + } + if (Math.hypot(dxHi, dyHi) <= radius) { + line.push(1); + continue; + } + let hits = 0; + for (let j = 0; j < SAMPLES; j++) { + const dy = y0 + (j + 0.5) * step * pixelH - cy; + for (let i = 0; i < SAMPLES; i++) { + const dx = x0 + (i + 0.5) * step * aspect - cx; + if (Math.hypot(dx, dy) <= radius) hits++; + } + } + line.push(hits / (SAMPLES * SAMPLES)); + } + grid.push(line); + } + return grid; +} + +/** Clear a band of pixel rows; GAP_ROW (text rows) marks the slot's middle, so + * the same numbers land in the same place whatever the tier's row density. */ +function carveGap(grid: number[][], ry: number): void { + const n = grid.length; + const thick = Math.max(1, Math.round(GAP_HEIGHT_ROWS * ry)); + const centre = GAP_ROW >= 0 ? GAP_ROW * ry : n + GAP_ROW * ry; + const start = Math.max(0, Math.min(n - thick, centre - thick / 2)); + for (let i = Math.trunc(start); i < Math.trunc(start + thick); i++) { + grid[i].fill(0); + } +} + +/** The mark as LOGO_COLS-wide rows. With a colour, glyphs are painted in it and + * full cells become background-coloured spaces; without, plain glyphs. */ +export function logoRows( + color?: string, + tier: LogoTier = detectTier(), +): string[] { + const { rx, ry, table } = tier === "octant" ? OCTANT : QUAD; + const grid = coverageGrid(ROWS * ry, LOGO_COLS * rx, ASPECT / rx, 1 / ry); + carveGap(grid, ry); + const fg = color ? chalk.hex(color) : (s: string) => s; + const bg = color ? chalk.bgHex(color) : (s: string) => s; + const full = (1 << (rx * ry)) - 1; + const out: string[] = []; + for (let r = 0; r < ROWS; r++) { + let row = ""; + for (let c = 0; c < LOGO_COLS; c++) { + let mask = 0; + for (let sr = 0; sr < ry; sr++) { + for (let sc = 0; sc < rx; sc++) { + if (grid[r * ry + sr][c * rx + sc] >= FILL) + mask |= 1 << (sr * rx + sc); + } + } + const glyph = table[mask]; + if (glyph === " ") row += " "; + else if (mask === full && color) row += bg(" "); + else row += fg(glyph); + } + out.push(row); + } + return out; +} diff --git a/packages/cli/src/cli/commands/code/paste.ts b/packages/cli/src/cli/commands/code/paste.ts new file mode 100644 index 000000000..4b404b5c6 --- /dev/null +++ b/packages/cli/src/cli/commands/code/paste.ts @@ -0,0 +1,93 @@ +import { PassThrough } from "node:stream"; + +const START = "\x1b[200~"; +const END = "\x1b[201~"; + +/** Longest suffix of `s` that is a prefix of `marker` — a paste marker can + * arrive split across stdin chunks. */ +function partialSuffix(s: string, marker: string): string { + for (let n = Math.min(marker.length - 1, s.length); n > 0; n--) { + if (marker.startsWith(s.slice(-n))) return s.slice(-n); + } + return ""; +} + +/** Stateful chunk sanitizer for bracketed paste: strips the markers and + * flattens pasted newlines/tabs to spaces so a multi-line paste lands in the + * input as ONE line instead of a submit per line. Pure — unit-testable. */ +export function makePasteSanitizer(): (chunk: string) => string { + let inPaste = false; + let carry = ""; + const clean = (t: string) => + t.replace(/\r\n|\r|\n/g, " ").replace(/\t/g, " "); + return (chunk: string): string => { + let s = carry + chunk; + carry = ""; + let out = ""; + while (s.length > 0) { + if (!inPaste) { + const i = s.indexOf(START); + if (i === -1) { + const tail = partialSuffix(s, START); + out += s.slice(0, s.length - tail.length); + carry = tail; + s = ""; + } else { + out += s.slice(0, i); + s = s.slice(i + START.length); + inPaste = true; + } + } else { + const j = s.indexOf(END); + if (j === -1) { + const tail = partialSuffix(s, END); + out += clean(s.slice(0, s.length - tail.length)); + carry = tail; + s = ""; + } else { + out += clean(s.slice(0, j)); + s = s.slice(j + END.length); + inPaste = false; + } + } + } + return out; + }; +} + +interface PasteFriendlyStdin extends NodeJS.ReadStream { + cleanup(): void; +} + +/** + * A stdin for Ink that understands bracketed paste. The caller enables mode + * 2004 on the terminal (which also silences iTerm's multi-line paste warning); + * this proxy strips the markers and flattens the pasted text before Ink or + * ink-text-input ever see it. + */ +export function createPasteFriendlyStdin( + real: NodeJS.ReadStream, +): PasteFriendlyStdin { + const out = new PassThrough(); + const sanitize = makePasteSanitizer(); + const onData = (buf: Buffer) => { + const text = sanitize(buf.toString("utf8")); + if (text) out.write(text); + }; + real.on("data", onData); + + // biome-ignore lint/suspicious/noExplicitAny: decorating a stream into Ink's expected stdin shape + const proxy = out as any; + proxy.isTTY = true; + proxy.setRawMode = (mode: boolean) => { + real.setRawMode?.(mode); + return proxy; + }; + proxy.ref = () => real.ref?.(); + proxy.unref = () => real.unref?.(); + proxy.cleanup = () => { + real.off("data", onData); + real.pause(); + }; + return proxy as PasteFriendlyStdin; +} diff --git a/packages/cli/src/cli/commands/code/pending-card.ts b/packages/cli/src/cli/commands/code/pending-card.ts new file mode 100644 index 000000000..6d871639a --- /dev/null +++ b/packages/cli/src/cli/commands/code/pending-card.ts @@ -0,0 +1,280 @@ +import chalk from "chalk"; +import type { ToolCallAction } from "@/core/resources/apps/api.js"; +import { + type ChoiceSelection, + choiceAnswers, + type PendingInput, +} from "@/core/resources/apps/pending.js"; + +/** + * The card the session shows when the agent is waiting on you. Pure state and + * pure rendering, so every keystroke path is testable without Ink. Secret + * values live here only until the answer is submitted and are never rendered. + */ +export interface CardState { + pending: PendingInput; + /** Question index (choice) or field index (secrets). */ + step: number; + /** Highlighted row: an option, "something else" (choice) or a permission row. */ + cursor: number; + /** Per question: selected labels and free text. */ + selections: ChoiceSelection[]; + /** Permission keys currently ticked. */ + granted: Set; + /** When the main input is capturing text for the card. */ + typing: "custom" | "secret" | null; + /** Secret name → value. Dropped on submit or dismissal. */ + secretValues: Record; +} + +export type CardKey = + | "up" + | "down" + | "space" + | "enter" + | "escape" + | "y" + | "n" + | "s"; + +interface CardOutcome { + state: CardState | null; + /** Post this answer. */ + submit?: { action: ToolCallAction; input: Record }; + /** The user chose "later": hide the card until Tab. */ + dismissed?: boolean; +} + +export function openCard(pending: PendingInput): CardState { + return { + pending, + step: 0, + cursor: 0, + selections: (pending.questions ?? []).map(() => ({ labels: [] })), + granted: new Set((pending.permissions ?? []).map((p) => p.key)), + typing: pending.kind === "secrets" ? "secret" : null, + secretValues: {}, + }; +} + +const done = ( + action: ToolCallAction, + input: Record = {}, +): CardOutcome => ({ state: null, submit: { action, input } }); +const later: CardOutcome = { state: null, dismissed: true }; + +/** Rows of the current choice question: its options, then "something else". */ +function choiceRows(state: CardState): number { + return (state.pending.questions?.[state.step]?.options.length ?? 0) + 1; +} + +function advanceChoice(state: CardState): CardOutcome { + const questions = state.pending.questions ?? []; + if (state.step + 1 < questions.length) { + return { + state: { ...state, step: state.step + 1, cursor: 0, typing: null }, + }; + } + return done("approved", choiceAnswers(questions, state.selections)); +} + +function choiceKey(state: CardState, key: CardKey): CardOutcome { + const question = state.pending.questions?.[state.step]; + if (!question) return done("approved", { answers: [] }); + const rows = choiceRows(state); + const custom = state.cursor === rows - 1; + const sel = state.selections[state.step] ?? { labels: [] }; + const setSel = (next: ChoiceSelection): CardState => { + const selections = [...state.selections]; + selections[state.step] = next; + return { ...state, selections }; + }; + switch (key) { + case "up": + return { state: { ...state, cursor: (state.cursor - 1 + rows) % rows } }; + case "down": + return { state: { ...state, cursor: (state.cursor + 1) % rows } }; + case "space": { + if (custom) return { state: { ...state, typing: "custom" } }; + const label = question.options[state.cursor].label; + const labels = question.multiSelect + ? sel.labels.includes(label) + ? sel.labels.filter((l) => l !== label) + : [...sel.labels, label] + : [label]; + return { state: setSel({ ...sel, labels }) }; + } + case "enter": { + if (custom) return { state: { ...state, typing: "custom" } }; + if (question.multiSelect) { + if (sel.labels.length === 0 && !sel.customText) return { state }; + return advanceChoice(state); + } + const label = question.options[state.cursor].label; + return advanceChoice(setSel({ labels: [label] })); + } + case "s": + return done("approved", { answers: [] }); // the web's "skip": don't re-ask + case "escape": + return later; + default: + return { state }; + } +} + +function permissionsKey(state: CardState, key: CardKey): CardOutcome { + const rows = state.pending.permissions ?? []; + switch (key) { + case "up": + return { + state: { + ...state, + cursor: (state.cursor - 1 + rows.length) % rows.length, + }, + }; + case "down": + return { state: { ...state, cursor: (state.cursor + 1) % rows.length } }; + case "space": { + const k = rows[state.cursor]?.key; + if (!k) return { state }; + const granted = new Set(state.granted); + if (granted.has(k)) granted.delete(k); + else granted.add(k); + return { state: { ...state, granted } }; + } + case "enter": + case "y": + return done("approved", { + approved_permission_keys: rows + .map((r) => r.key) + .filter((k) => state.granted.has(k)), + }); + case "n": + return done("rejected"); + case "escape": + return later; + default: + return { state }; + } +} + +/** A key while the card owns the keyboard. */ +export function cardKey(state: CardState, key: CardKey): CardOutcome { + if (state.typing) { + // The input box has the keys; only Esc backs out of typing. + if (key === "escape") { + return state.typing === "secret" + ? later // dropping the card drops any values typed so far + : { state: { ...state, typing: null } }; + } + return { state }; + } + switch (state.pending.kind) { + case "choice": + return choiceKey(state, key); + case "permissions": + return permissionsKey(state, key); + default: + // approval and browser steps: yes / no / later + if (key === "y" || key === "enter") return done("approved"); + if (key === "n") return done("rejected"); + if (key === "escape") return later; + return { state }; + } +} + +/** A line the user typed into the input box while the card was capturing it. */ +export function cardText(state: CardState, text: string): CardOutcome { + const value = text.trim(); + if (state.typing === "custom") { + if (!value) return { state: { ...state, typing: null } }; + const selections = [...state.selections]; + const current = selections[state.step] ?? { labels: [] }; + selections[state.step] = { ...current, customText: value }; + return advanceChoice({ ...state, selections, typing: null }); + } + if (state.typing === "secret") { + const fields = state.pending.secrets ?? []; + const field = fields[state.step]; + if (!field || !value) return { state }; // empty value: stay on the field + const secretValues = { ...state.secretValues, [field.name]: value }; + if (state.step + 1 < fields.length) { + return { state: { ...state, secretValues, step: state.step + 1 } }; + } + return done("approved", { secrets: secretValues }); + } + return { state }; +} + +/** Everything the card shows, as terminal rows. Secret values never appear. */ +export function cardLines(state: CardState): string[] { + const p = state.pending; + const head = [chalk.bold(`⏸ ${p.title}`)]; + if (p.detail) head.push(chalk.dim(` ${p.detail}`)); + switch (p.kind) { + case "choice": { + const q = p.questions?.[state.step]; + if (!q) + return [...head, chalk.dim(" (no questions) · Enter to continue")]; + const total = p.questions?.length ?? 1; + const sel = state.selections[state.step] ?? { labels: [] }; + const rows = q.options.map((o, i) => { + const on = sel.labels.includes(o.label); + const mark = q.multiSelect ? (on ? "☑" : "☐") : on ? "●" : "○"; + const text = `${state.cursor === i ? "▸" : " "} ${mark} ${o.label}${o.description ? chalk.dim(` — ${o.description}`) : ""}`; + return state.cursor === i ? chalk.cyan(text) : text; + }); + const customRow = `${state.cursor === q.options.length ? "▸" : " "} ✎ something else${sel.customText ? chalk.dim(` — ${sel.customText}`) : ""}`; + rows.push( + state.cursor === q.options.length ? chalk.cyan(customRow) : customRow, + ); + return [ + ...head, + ` ${chalk.bold(q.question)} ${chalk.dim(`(${state.step + 1}/${total})`)}`, + ...(q.description ? [chalk.dim(` ${q.description}`)] : []), + ...rows.map((r) => ` ${r}`), + chalk.dim( + q.multiSelect + ? " ↑↓ move · space toggle · Enter next · s skip all · Esc later" + : " ↑↓ move · Enter choose · s skip all · Esc later", + ), + ]; + } + case "permissions": { + const rows = (p.permissions ?? []).map((r, i) => { + const text = `${state.cursor === i ? "▸" : " "} ${state.granted.has(r.key) ? "☑" : "☐"} ${r.label}${r.reason ? chalk.dim(` — ${r.reason}`) : ""}`; + return ` ${state.cursor === i ? chalk.cyan(text) : text}`; + }); + return [ + ...head, + ...rows, + chalk.dim( + " space toggle · Enter grant ticked · n reject all · Esc later", + ), + ]; + } + case "secrets": { + const fields = p.secrets ?? []; + const rows = fields.map((f, i) => { + const filled = f.name in state.secretValues; + const mark = filled ? chalk.green("✓") : i === state.step ? "▸" : "○"; + return ` ${mark} ${f.name}${f.description ? chalk.dim(` — ${f.description}`) : ""}`; + }); + return [ + ...head, + ...rows, + chalk.dim(" type the value below (hidden) · Enter next · Esc later"), + ]; + } + case "browser": + return [ + ...head, + chalk.dim( + " finish this step in the editor (footer link), then press y", + ), + chalk.dim(" y continue · n reject · Esc later"), + ]; + default: + return [...head, chalk.dim(" y approve · n reject · Esc later")]; + } +} diff --git a/packages/cli/src/cli/commands/code/render.ts b/packages/cli/src/cli/commands/code/render.ts new file mode 100644 index 000000000..f6841bfad --- /dev/null +++ b/packages/cli/src/cli/commands/code/render.ts @@ -0,0 +1,470 @@ +import chalk from "chalk"; +import type { StreamEvent } from "@/core/resources/apps/stream.js"; + +const TOOL_ALIASES: Record = { + run_shell_command: "bash", + read_repo_file: "read", + write_repo_file: "write", + edit_repo_file: "edit", + read_file: "read", + write_file: "write", + find_replace: "edit", + delete_file: "delete", + set_secrets: "secrets", + generate_development_secrets: "secrets", + create_pull_request: "pr", + merge_pull_request: "merge", + list_pr_threads: "pr threads", + reply_to_pr_thread: "pr reply", + comment_on_pr: "pr comment", + resolve_pr_thread: "pr resolve", + reload_preview: "reload", + preview_execute_code: "preview js", + preview_screenshot: "screenshot", + connect_github_account: "github", +}; + +// Verbose result text adds nothing for these; the path in the summary does. +const QUIET_OK_RESULTS = new Set(["read", "write", "edit", "reload", "delete"]); + +/** Folding: what a tool result shows before you ask for the rest. */ +const RESULT_LINE_MAX = 110; +const DIFF_LINES_MAX = 12; +const ERROR_LINES_MAX = 8; + +interface EventLineOptions { + /** Show every line of every result: no folding, no truncation. */ + verbose?: boolean; + /** How to unfold, named in the fold marker (e.g. "Ctrl+O to expand"). */ + foldHint?: string; + /** Where a parked tool gets answered (default: the editor). */ + waitingHint?: string; +} + +/** A transcript item: a finished, styled line, or a tool result kept as data so + * the fold can be toggled after the fact. */ +export type TranscriptEntry = + | string + | { event: Extract; elapsedMs?: number } + | { + running: Extract; + startedAt: number; + }; + +export function renderEntry( + entry: TranscriptEntry, + options: EventLineOptions = {}, +): string { + if (typeof entry === "string") return entry; + if ("running" in entry) return runningLine(entry.running, entry.startedAt); + return eventLine(entry.event, entry.elapsedMs, options) ?? ""; +} + +const RUNNING_DOT = "#E86B3C"; +const PULSE_MS = 500; + +/** A tool still running, in place in the transcript: a pulsing dot, the tool's + * title, how long it has been at it, and its command or path underneath. The + * finished line replaces it. */ +export function runningLine( + event: Extract, + startedAt: number, + now: number = Date.now(), +): string { + const lit = Math.floor(now / PULSE_MS) % 2 === 0; + const dot = lit ? chalk.hex(RUNNING_DOT)("●") : chalk.dim("●"); + const alias = toolAlias(event.name); + const title = chalk.bold(event.label || alias); + const inline = + !event.label && event.summary ? ` ${chalk.dim(event.summary)}` : ""; + const seconds = Math.round((now - startedAt) / 1000); + const took = seconds >= 3 ? ` ${chalk.dim(`· ${seconds}s`)}` : ""; + const detail = + event.label && event.summary + ? `\n ${chalk.dim(`└ ${alias}: ${event.summary}`)}` + : ""; + return `${dot} ${title}${inline}${took}${detail}`; +} + +function fold( + lines: string[], + max: number, + options: EventLineOptions, +): string[] { + if (options.verbose || lines.length <= max) return lines; + const hint = options.foldHint ? ` (${options.foldHint})` : ""; + return [ + ...lines.slice(0, max), + chalk.dim(`… +${lines.length - max} lines${hint}`), + ]; +} + +const str = (v: unknown): string | undefined => + typeof v === "string" ? v : undefined; + +/** An edit as the change itself: the removed text, then the inserted text. */ +function diffLines(args: Record): string[] | null { + const find = str(args.find); + const replace = str(args.replace); + if (find == null && replace == null) return null; + return [ + ...(find ?? "").split("\n").map((l) => chalk.red(`- ${l}`)), + ...(replace ?? "").split("\n").map((l) => chalk.green(`+ ${l}`)), + ]; +} + +const indent = (lines: string[]): string => + lines.map((l) => ` ${l}`).join("\n"); + +export function toolAlias(name: string): string { + return TOOL_ALIASES[name] ?? name; +} + +// Terminal control bytes, built from char codes so this source carries no raw +// ESC/BEL and no ambiguous escape literals. +const ESC_CHAR = String.fromCharCode(27); +const BEL = String.fromCharCode(7); +const OSC8_CLOSE = `${ESC_CHAR}]8;;${BEL}`; + +/** OSC 8 terminal hyperlink: a short clickable label instead of a wrapping + * URL - the whole link opens regardless of line width. */ +export function terminalLink(label: string, url: string): string { + return `${ESC_CHAR}]8;;${url}${BEL}${chalk.dim.underline(label)}${OSC8_CLOSE}`; +} + +/** Wrap bare http(s) URLs in a plain-text string as OSC 8 hyperlinks, so a URL + * a hard wrap would split still opens in full on ctrl/cmd-click. Each link gets + * an `id=` so terminals join its segments across wrapped rows (paired with + * hardWrapAnsi, which reopens the active link on every continuation row). The + * visible text stays the URL. Input must be plain text (no existing OSC 8), so + * only call it on raw backend text, never on already-linked output. */ +function linkifyUrls(text: string): string { + let n = 0; + return text.replace(/https?:\/\/[^\s]+/g, (raw) => { + // Trailing sentence punctuation is not part of the URL. + const trailing = raw.match(/[.,;:!?)\]}'"]+$/)?.[0] ?? ""; + const url = trailing ? raw.slice(0, -trailing.length) : raw; + const id = `b44-${n++}`; + return `${ESC_CHAR}]8;id=${id};${url}${BEL}${url}${OSC8_CLOSE}${trailing}`; + }); +} + +/** Hard-wrap ANSI-styled text at `width` visible columns, keeping style + * continuity across breaks (reset at the break, reopen the active SGR codes and + * any active OSC 8 hyperlink so a wrapped link stays whole). Narrow but + * dependency-free - all input here is our own chalk / linkifyUrls output. */ +export function hardWrapAnsi(text: string, width: number): string[] { + const ESC = new RegExp( + `^(?:${ESC_CHAR}\\[[0-9;]*m|${ESC_CHAR}\\]8;[^${BEL}]*${BEL})`, + ); + const RESET = `${ESC_CHAR}[0m`; + const out: string[] = []; + for (const logical of text.split("\n")) { + let line = ""; + let visible = 0; + let active: string[] = []; + let link = ""; // the active OSC 8 open sequence, or "" when none is open + let i = 0; + while (i < logical.length) { + const esc = ESC.exec(logical.slice(i)); + if (esc) { + const seq = esc[0]; + line += seq; + if (seq === RESET) active = []; + else if (seq.endsWith("m")) active.push(seq); + else if (seq === OSC8_CLOSE) link = ""; + else link = seq; // an OSC 8 open (carries id + url) + i += seq.length; + continue; + } + if (visible >= width) { + // Close the link before the break, then reopen it (same id) on the next + // row so the terminal treats both halves as one hyperlink. + out.push(`${line}${link ? OSC8_CLOSE : ""}${RESET}`); + line = active.join("") + link; + visible = 0; + } + line += logical[i]; + visible++; + i++; + } + out.push(line); + } + return out; +} + +export function formatDuration(ms: number): string { + const seconds = Math.round(ms / 1000); + if (seconds < 90) return `${seconds}s`; + return `${Math.floor(seconds / 60)}m ${String(seconds % 60).padStart(2, "0")}s`; +} + +/** + * The finished line for an event, or null when it only affects the live + * status (a tool starting). The tool's own human title (its `summary` + * argument, same as the editor shows) leads; the raw salient argument is the + * dim detail. Plain string + chalk; no layout gutter. + */ +export function eventLine( + event: StreamEvent, + elapsedMs?: number, + options: EventLineOptions = {}, +): string | null { + switch (event.kind) { + case "thinking": + return chalk.dim(`✻ ${event.text}`); + case "text": + return linkifyUrls(event.text); + case "tool_start": + return null; + case "waiting": { + const what = event.label || toolAlias(event.name); + return chalk.yellow( + `⏸ ${what} — needs your input (${options.waitingHint ?? "answer in the editor"})`, + ); + } + case "tool_end": { + const alias = toolAlias(event.name); + const mark = event.ok ? chalk.green("✓") : chalk.red("✗"); + const title = chalk.bold(event.label || alias); + const took = + elapsedMs != null && elapsedMs >= 3000 + ? ` ${chalk.dim(`· ${formatDuration(elapsedMs)}`)}` + : ""; + // With a human title, the raw params move to their own dim line; a bare + // alias keeps a short param (a path) inline. + const inlineDetail = + !event.label && event.summary ? ` ${chalk.dim(event.summary)}` : ""; + const paramsLine = + event.label && event.summary + ? `\n ${chalk.dim(`${alias}: ${event.summary}`)}` + : ""; + const head = `${mark} ${title}${inlineDetail}${took}${paramsLine}`; + const args = event.args ?? null; + if (event.ok && alias === "edit" && args) { + // The change itself, Claude-Code style, instead of "Success". + const diff = diffLines(args); + if (diff) + return `${head}\n${indent(fold(diff, DIFF_LINES_MAX, options))}`; + } + if (event.ok && alias === "write" && args && str(args.content) != null) { + const lines = (str(args.content) as string).split("\n"); + const count = chalk.dim(`+${lines.length} lines`); + if (!options.verbose) return `${head} ${count}`; + return `${head} ${count}\n${indent(lines.map((l) => chalk.dim(l)))}`; + } + if (event.ok && (QUIET_OK_RESULTS.has(alias) || !event.result)) { + return head; + } + const paint = event.ok ? chalk.dim : chalk.red; + const lines = event.result + .split("\n") + .map((l) => l.trimEnd()) + .filter((l, i, all) => l.length > 0 || (i > 0 && i < all.length - 1)); + if (event.ok && !options.verbose) { + // One line of a successful result; the rest is a keypress away. + const first = lines[0] ?? ""; + const cut = + first.length > RESULT_LINE_MAX + ? `${first.slice(0, RESULT_LINE_MAX)}…` + : first; + const more = lines.length - 1; + const hint = options.foldHint ? ` (${options.foldHint})` : ""; + const tail = more > 0 ? chalk.dim(` … +${more} lines${hint}`) : ""; + return `${head}\n ${paint(linkifyUrls(cut))}${tail}`; + } + // Errors are never cut mid-sentence: a few full lines, then a fold. + const shown = fold( + lines.map((l) => paint(linkifyUrls(l))), + event.ok ? Number.POSITIVE_INFINITY : ERROR_LINES_MAX, + options, + ); + return `${head}\n${indent(shown)}`; + } + } +} + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +// The thinking glyph pulses through these and back, Claude-Code style. +const SHIMMER = ["·", "✢", "✳", "✶", "✻", "✽"]; +const SHIMMER_STEP_MS = 120; + +/** The pulsing "thinking" glyph for a given moment. */ +export function shimmer(now: number = Date.now()): string { + const period = SHIMMER.length * 2 - 2; // forward then back, no repeated ends + const step = Math.floor(now / SHIMMER_STEP_MS) % period; + const index = step < SHIMMER.length ? step : period - step; + return SHIMMER[index]; +} + +// Idle-gap gerunds, one at a time, rotating every few seconds. +const MUSINGS = [ + "Shmoozing", + "Shmoogling", + "Percolating", + "Noodling", + "Marinating", + "Brewing", + "Simmering", + "Conjuring", + "Tinkering", + "Scheming", + "Pondering", + "Mulling", + "Whirring", + "Crunching", + "Weaving", + "Sketching", + "Hatching", + "Riffing", + "Cooking", + "Composting ideas", + "Rummaging", + "Vibing responsibly", + "Untangling", + "Squinting at the repo", +]; +const MUSING_ROTATE_MS = 6_000; + +/** The rotating idle gerund for a given session seed. */ +export function idleMusing(seed: number): string { + return `${MUSINGS[(seed + Math.floor(Date.now() / MUSING_ROTATE_MS)) % MUSINGS.length]}…`; +} + +interface RunningTool { + alias: string; + label: string; + summary: string; + startedAt: number; +} + +interface TurnStream { + onEvent: (event: StreamEvent) => void; + stop: () => void; +} + +interface TurnStreamOptions { + /** Shown while no tool is running — e.g. the pre-first-turn provisioning wait. Defaults to a rotating musing. */ + idleLabel?: string; + /** Lines pinned under the stream (repo/editor/preview links) — always the + * bottom of the terminal while streaming, printed permanently on stop. The + * array is read LIVE: pushing a line (e.g. the preview URL once fetched) + * makes it appear on the next tick. Keep each line under a typical terminal + * width: a soft-wrapped footer line breaks the redraw arithmetic. */ + footer?: string[]; + /** Render every result unfolded (`--verbose`). */ + verbose?: boolean; +} + +/** + * Claude-Code-style turn view: completed items print as compact lines while a + * live block at the bottom shows the pinned footer links and a spinner status + * line (running tool + elapsed seconds). Non-interactive mode skips the live + * block and just prints settled lines. + */ +export function createTurnStream( + interactive: boolean, + write: (text: string) => void = (text) => process.stdout.write(text), + options: TurnStreamOptions = {}, +): TurnStream { + const running = new Map(); + const footer = options.footer ?? []; + let frame = 0; + let stopped = false; + let drawnLines = 0; + const musingSeed = Math.floor(Math.random() * MUSINGS.length); + + const statusLabel = (): string => { + if (running.size === 0) { + if (options.idleLabel) return `${options.idleLabel}…`; + const index = + (musingSeed + Math.floor(Date.now() / MUSING_ROTATE_MS)) % + MUSINGS.length; + return `${MUSINGS[index]}…`; + } + const newest = [...running.values()].at(-1) as RunningTool; + const elapsed = Math.round((Date.now() - newest.startedAt) / 1000); + const others = running.size > 1 ? ` (+${running.size - 1} more)` : ""; + const what = + newest.label || + `${newest.alias}${newest.summary ? ` ${newest.summary}` : ""}`; + return `${what}${others} · ${elapsed}s`; + }; + + const clearBlock = () => { + if (!drawnLines) return; + write("\r\x1b[2K"); + for (let i = 1; i < drawnLines; i++) write("\x1b[1A\r\x1b[2K"); + drawnLines = 0; + }; + + const drawBlock = () => { + if (!interactive || stopped) return; + // Leading blank line keeps the pinned links visually apart from the stream. + const lines = [ + ...(footer.length ? ["", ...footer] : []), + running.size === 0 + ? `${chalk.magenta(shimmer())} ${chalk.dim(statusLabel())}` + : chalk.dim(`${FRAMES[frame]} ${statusLabel()}`), + ]; + write(lines.join("\n")); + drawnLines = lines.length; + }; + + const tick = () => { + if (!interactive || stopped) return; + frame = (frame + 1) % FRAMES.length; + clearBlock(); + drawBlock(); + }; + + const timer = interactive ? setInterval(tick, 120) : null; + if (timer) timer.unref?.(); + + return { + onEvent(event: StreamEvent) { + if (event.kind === "tool_start") { + running.set(event.id, { + alias: toolAlias(event.name), + label: event.label, + summary: event.summary, + startedAt: Date.now(), + }); + if (interactive) { + clearBlock(); + drawBlock(); + } + return; + } + let elapsedMs: number | undefined; + if (event.kind === "tool_end") { + const started = running.get(event.id)?.startedAt; + if (started != null) elapsedMs = Date.now() - started; + running.delete(event.id); + } + const line = eventLine(event, elapsedMs, { + verbose: options.verbose, + foldHint: "--verbose shows everything", + }); + if (line == null) return; + if (interactive) { + clearBlock(); + write(`${line}\n`); + drawBlock(); + } else { + write(`${line}\n`); + } + }, + stop() { + if (interactive) { + clearBlock(); + // The links outlive the stream — leave them printed for clicking, set + // apart from the prose above. + if (footer.length) write(`\n${footer.join("\n")}\n`); + } + stopped = true; + if (timer) clearInterval(timer); + }, + }; +} diff --git a/packages/cli/src/cli/commands/code/session-engine.ts b/packages/cli/src/cli/commands/code/session-engine.ts new file mode 100644 index 000000000..4da2d3b66 --- /dev/null +++ b/packages/cli/src/cli/commands/code/session-engine.ts @@ -0,0 +1,373 @@ +import chalk from "chalk"; +import { + eventLine, + formatDuration, + type TranscriptEntry, + toolAlias, +} from "@/cli/commands/code/render.js"; +import { ApiError } from "@/core/errors.js"; +import { + answerToolCall, + getFullConversation, + sendTurn, + stopTurn, + type ToolCallAction, +} from "@/core/resources/apps/api.js"; +import { + type PendingInput, + pendingInputs, +} from "@/core/resources/apps/pending.js"; +import { + diffConversation, + newestUserTurn, + newStreamState, +} from "@/core/resources/apps/stream.js"; + +const POLL_MS = 1_000; + +interface RunningTool { + alias: string; + label: string; + summary: string; + startedAt: number; +} + +export interface TurnSettleInfo { + turnIndex: number; + ok: boolean; + backendStatus?: string; + durationMs: number; +} + +type SessionPhase = "awaiting" | "running" | "sending" | "idle"; + +export interface SessionStatus { + phase: SessionPhase; + awaitingLabel?: string; + idleHint?: string; + awaitingSince: number; + turnStartedAt: number | null; + runningTool: { + label: string; + alias: string; + summary: string; + startedAt: number; + others: number; + } | null; + lastTurnMs: number | null; + lastTurnOk: boolean; + /** ms since the running turn last produced a visible event. */ + quietForMs: number; + /** Tool calls the agent parked for you, oldest first, minus ones already answered here. */ + pending: PendingInput[]; +} + +interface EngineOptions { + branchId?: string; + awaitingTurnLabel?: string; + idleHint?: string; + /** Scrollback sink: styled lines, or tool results kept as data for the fold. */ + onLine: (entry: TranscriptEntry) => void; + onTurnSettled?: (info: TurnSettleInfo) => void | Promise; +} + +export interface SessionEngine { + start(primeFirstPoll: boolean): Promise; + stop(): void; + submit(text: string): void; + /** Answer a parked tool call. Posts, then lets the poller stream the turn it resumes. */ + answer( + pending: PendingInput, + action: ToolCallAction, + input?: Record, + ): void; + /** Stop the running turn server-side (like the editor's stop button). */ + stopTurn(): void; + status(): SessionStatus; + turnRunning(): boolean; +} + +/** + * Everything about a session except pixels: the persistent conversation + * watcher, turn-state derivation from the newest user message's outcome + * stamp, and message submission (including mid-turn sends the backend + * queues). Emits already-styled scrollback lines through `onLine`; the UI + * layer renders them plus a status snapshot. + */ +export function createSessionEngine(options: EngineOptions): SessionEngine { + const running = new Map(); + const diffState = newStreamState(); + + let stopped = false; + let polling = false; + let pollStartedAt = 0; + let timer: ReturnType | null = null; + let sendsInFlight = 0; + let activeTurnId: string | null = null; + let turnStartedAt: number | null = null; + let pendingSubmitAt: number | null = null; + let lastTurnMs: number | null = null; + let lastTurnOk = true; + let settledCount = 0; + let awaitingTurn = options.awaitingTurnLabel ?? null; + const awaitingSince = Date.now(); + let lastEventAt = Date.now(); + + let pending: PendingInput[] = []; + const answered = new Set(); + + const answer = ( + p: PendingInput, + action: ToolCallAction, + input: Record = {}, + ) => { + answered.add(p.toolCallId); + pending = pending.filter((x) => x.toolCallId !== p.toolCallId); + const verb = + action === "rejected" + ? chalk.red("✗ rejected") + : Object.keys(input).length + ? chalk.green("→ answered") + : chalk.green("✓ approved"); + options.onLine(`${verb} ${chalk.dim("—")} ${p.title}`); + pendingSubmitAt = Date.now(); + sendsInFlight++; + answerToolCall( + { toolCallId: p.toolCallId, messageId: p.messageId, action, input }, + options.branchId, + ) + .catch((error: unknown) => { + // Like submit: an edge timeout after the backend took the answer is + // not a failure — the resumed turn shows in the stream. + const status = error instanceof ApiError ? error.statusCode : undefined; + const edgeDrop = + status === 502 || + status === 503 || + status === 504 || + /timeout|gateway/i.test(error instanceof Error ? error.message : ""); + if (edgeDrop && (turnStartedAt != null || pendingSubmitAt == null)) { + return; + } + answered.delete(p.toolCallId); + pendingSubmitAt = null; + options.onLine( + chalk.red( + `✗ answer failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }) + .finally(() => { + sendsInFlight--; + }); + }; + + const submit = (raw: string) => { + const typed = raw.trim(); + if (!typed) return; + const text = typed; + options.onLine(`${chalk.cyan("❯")} ${chalk.bold(typed)}`); + pendingSubmitAt = Date.now(); + const submitTurnId = activeTurnId; + sendsInFlight++; + sendTurn(text, options.branchId) + .then((turn) => { + if (turn.queued) { + options.onLine(chalk.dim("· queued — runs after the current turn")); + } + }) + .catch((error: unknown) => { + // The chat request stays open for the whole turn, so a long turn trips + // the edge's request timeout (~100s) with a 5xx even though + // the message reached the backend and the turn is running. If the + // poller has since picked up a new turn (activeTurnId advanced, or the + // submit marker was consumed), the send was delivered — not a failure. + const delivered = + activeTurnId !== submitTurnId || + turnStartedAt != null || + pendingSubmitAt == null; + const status = error instanceof ApiError ? error.statusCode : undefined; + const edgeDrop = + status === 502 || + status === 503 || + status === 504 || + /timeout|gateway/i.test(error instanceof Error ? error.message : ""); + if (edgeDrop && delivered) return; // Running — the stream shows it. + pendingSubmitAt = null; + const message = error instanceof Error ? error.message : String(error); + options.onLine(chalk.red(`✗ send failed: ${message}`)); + }) + .finally(() => { + sendsInFlight--; + }); + }; + + const poll = async (prime: boolean) => { + // Re-entrancy guard, but time-bounded: if a previous poll's request wedged + // (a hung fetch that never resolves or rejects), a plain boolean would block + // every future poll forever — the turn settles server-side but the UI stays + // stuck on "running" with the timer ticking. After STUCK_POLL_MS, let a new + // poll through so settle is still detected. + const STUCK_POLL_MS = 45_000; + if (polling && Date.now() - pollStartedAt < STUCK_POLL_MS) return; + polling = true; + pollStartedAt = Date.now(); + try { + let messages: Awaited>; + try { + messages = await getFullConversation(30, options.branchId); + } catch { + return; // Transient — next tick retries. + } + const events = diffConversation(diffState, messages); + // What the agent is waiting on right now; an id we answered stays hidden + // until the backend no longer reports it waiting. + const waiting = pendingInputs(messages); + for (const id of answered) { + if (!waiting.some((w) => w.toolCallId === id)) answered.delete(id); + } + pending = waiting.filter((w) => !answered.has(w.toolCallId)); + if (!prime) { + for (const event of events) { + if (event.kind === "tool_start") { + const startedAt = Date.now(); + running.set(event.id, { + alias: toolAlias(event.name), + label: event.label, + summary: event.summary, + startedAt, + }); + // Shown in place while it runs; the finished line replaces it. + options.onLine({ running: event, startedAt }); + continue; + } + if (event.kind === "tool_end") { + const started = running.get(event.id)?.startedAt; + running.delete(event.id); + lastEventAt = Date.now(); + options.onLine({ + event, + elapsedMs: started != null ? Date.now() - started : undefined, + }); + continue; + } + const line = eventLine(event, undefined, { + waitingHint: "answer in the card below", + }); + if (line != null) { + lastEventAt = Date.now(); + options.onLine(line); + } + } + } + + const turn = newestUserTurn(messages); + if (!turn) return; + const kickoffDetection = awaitingTurn != null && activeTurnId === null; + awaitingTurn = null; + if (turn.id !== activeTurnId) { + activeTurnId = turn.id; + if (!turn.settled) { + // A kickoff was already running before this session opened — count + // its time from session start. Later turns count from their submit. + turnStartedAt = + pendingSubmitAt ?? (kickoffDetection ? awaitingSince : Date.now()); + pendingSubmitAt = null; + running.clear(); + } else if (prime) { + // Session opened onto an already-finished turn — nothing to track. + turnStartedAt = null; + } + } + if (turn.settled && turnStartedAt != null && turn.id === activeTurnId) { + const durationMs = Date.now() - turnStartedAt; + turnStartedAt = null; + running.clear(); + lastTurnMs = durationMs; + const ok = !turn.backendStatus?.startsWith("error"); + lastTurnOk = ok; + options.onLine( + ok + ? chalk.dim(`— turn finished · ${formatDuration(durationMs)}`) + : chalk.red( + `— turn failed (${turn.backendStatus ?? "unknown"}) · ${formatDuration(durationMs)}`, + ), + ); + const info: TurnSettleInfo = { + turnIndex: settledCount++, + ok, + backendStatus: turn.backendStatus, + durationMs, + }; + try { + await options.onTurnSettled?.(info); + } catch { + // A settle hook failure must not kill the session. + } + } + } finally { + polling = false; + } + }; + + return { + async start(primeFirstPoll: boolean) { + await poll(primeFirstPoll); + timer = setInterval(() => { + if (!stopped) void poll(false); + }, POLL_MS); + timer.unref?.(); + }, + stop() { + stopped = true; + if (timer) clearInterval(timer); + }, + stopTurn() { + // Nothing running (or already sending nothing) — no-op so Esc stays free + // for scroll-to-live when idle. + if ( + turnStartedAt == null && + sendsInFlight === 0 && + pendingSubmitAt == null + ) + return; + options.onLine(chalk.dim("· stopping…")); + // Fire-and-forget: the backend persists the stopped status, and the poller + // settles the turn from the transcript — same path as a natural finish. + stopTurn(options.branchId).catch((error: unknown) => { + options.onLine( + chalk.red( + ` stop failed: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + }); + }, + submit, + answer, + status(): SessionStatus { + let phase: SessionPhase = "idle"; + if (awaitingTurn != null) phase = "awaiting"; + else if (turnStartedAt != null) phase = "running"; + else if (sendsInFlight > 0 || pendingSubmitAt != null) phase = "sending"; + let runningTool: SessionStatus["runningTool"] = null; + if (running.size > 0) { + const newest = [...running.values()].at(-1) as RunningTool; + runningTool = { ...newest, others: running.size - 1 }; + } + return { + phase, + awaitingLabel: awaitingTurn ?? undefined, + idleHint: options.idleHint, + quietForMs: Date.now() - lastEventAt, + awaitingSince, + turnStartedAt, + runningTool, + lastTurnMs, + lastTurnOk, + pending, + }; + }, + turnRunning() { + return turnStartedAt != null; + }, + }; +} diff --git a/packages/cli/src/cli/commands/code/session.tsx b/packages/cli/src/cli/commands/code/session.tsx new file mode 100644 index 000000000..37bcf13b6 --- /dev/null +++ b/packages/cli/src/cli/commands/code/session.tsx @@ -0,0 +1,808 @@ +import chalk from "chalk"; +import { Box, render, Text, useApp, useInput } from "ink"; +import TextInput from "ink-text-input"; +import { useEffect, useReducer, useRef, useState } from "react"; +import { LOGO_COLS, logoRows } from "@/cli/commands/code/logo.js"; +import { createPasteFriendlyStdin } from "@/cli/commands/code/paste.js"; +import { + type CardKey, + type CardState, + cardKey, + cardLines, + cardText, + openCard, +} from "@/cli/commands/code/pending-card.js"; +import { + formatDuration, + hardWrapAnsi, + idleMusing, + renderEntry, + shimmer, + type TranscriptEntry, + terminalLink, +} from "@/cli/commands/code/render.js"; +import type { + SessionEngine, + SessionStatus, + TurnSettleInfo, +} from "@/cli/commands/code/session-engine.js"; +import { createSessionEngine } from "@/cli/commands/code/session-engine.js"; +import { readAuth } from "@/core/auth/config.js"; +import { getBase44ApiUrl } from "@/core/config.js"; +import { + displayName, + getMe, + MODELS, + resolvePick, + saveBuilderModel, +} from "@/core/model.js"; +import { + isGithubUserTokenError, + startGithubReauth, +} from "@/core/resources/apps/api.js"; +import packageJson from "../../../../package.json"; + +const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const BRAND_ORANGE = "#E86B3C"; + +// Alternate screen (Claude Code model): the session owns the viewport with +// its own internal scroll; the shell screen is restored untouched on exit. +// Mode 1007 makes the mouse wheel send arrow keys, which drive the scroll. +let altScreenActive = false; +let altExitHooked = false; +function enterAltScreen(): void { + process.stdout.write("\x1b[?1049h\x1b[?1007h\x1b[2J\x1b[H"); + altScreenActive = true; + if (!altExitHooked) { + altExitHooked = true; + process.on("exit", () => { + if (altScreenActive) process.stdout.write("\x1b[?1007l\x1b[?1049l"); + }); + } +} +function exitAltScreen(): void { + if (!altScreenActive) return; + altScreenActive = false; + process.stdout.write("\x1b[?1007l\x1b[?1049l"); +} + +interface SessionOptions { + branchId?: string; + /** Live footer lines (repo/editor/preview) — pushing appends to the block. */ + footer: string[]; + /** Swallow whatever the conversation already holds before showing anything — + * false for a fresh create, whose kickoff turn IS the history. */ + primeFirstPoll: boolean; + /** Sent as the first turn right after priming (the `chat` argument). */ + initialMessage?: string; + /** A turn is already starting server-side (the create kickoff): show this + * as the busy label until its user message appears, instead of "ready". */ + awaitingTurnLabel?: string; + /** Shown next to "ready" when nothing is running. */ + idleHint?: string; + onTurnSettled?: (info: TurnSettleInfo) => void | Promise; +} + +/** A finished tool replaces its own running line; everything else appends. */ +function withEntry( + items: TranscriptEntry[], + entry: TranscriptEntry, +): TranscriptEntry[] { + if (typeof entry !== "string" && "event" in entry) { + const i = items.findIndex( + (e) => + typeof e !== "string" && + "running" in e && + e.running.id === entry.event.id, + ); + if (i >= 0) { + const next = [...items]; + next[i] = entry; + return next; + } + } + return [...items, entry]; +} + +function statusText(status: SessionStatus, musingSeed: number): string { + const frame = FRAMES[Math.floor(Date.now() / 120) % FRAMES.length]; + switch (status.phase) { + case "awaiting": + return chalk.dim( + `${frame} ${status.awaitingLabel} (${formatDuration(Date.now() - status.awaitingSince)})`, + ); + case "running": { + const turnFor = formatDuration( + Date.now() - (status.turnStartedAt ?? Date.now()), + ); + // Running tools show in place in the transcript (pulsing dot); this + // line keeps the turn's own clock. + // Long silent stretch: some arms (plan/design) run minutes-long model + // calls whose UI renders only in the editor — say so instead of + // looking frozen. + const quiet = + status.quietForMs > 60_000 + ? " · a long private step — details render in the editor" + : ""; + return `${chalk.magenta(shimmer())} ${chalk.dim(`${idleMusing(musingSeed)} (${turnFor})${quiet}`)}`; + } + case "sending": + return chalk.dim(`${frame} sending…`); + case "idle": { + const hint = status.idleHint ? ` — ${status.idleHint}` : ""; + const last = + status.lastTurnMs != null + ? ` · last turn ${formatDuration(status.lastTurnMs)}${status.lastTurnOk ? "" : " (failed)"}` + : ""; + return chalk.dim(`ready${hint}${last}`); + } + } +} + +interface ViewProps { + engine: SessionEngine; + footer: string[]; + subscribe: (listener: (entry: TranscriptEntry) => void) => () => void; +} + +function SessionView({ engine, footer, subscribe }: ViewProps) { + const { exit } = useApp(); + const [items, setItems] = useState([]); + const [verbose, setVerbose] = useState(false); // Ctrl+O: unfold every tool result + const [input, setInput] = useState(""); + const [scroll, setScroll] = useState(0); // lines up from the live bottom + const [, tick] = useReducer((x: number) => x + 1, 0); + const [musingSeed] = useState(() => Math.floor(Math.random() * 97)); + const [currentModel, setCurrentModel] = useState(null); + const [pickerIndex, setPickerIndex] = useState(null); // null = closed + const [card, setCard] = useState(null); // the agent's question, when it has one + const dismissedRef = useRef(new Set()); // cards closed with Esc, until Tab + const maxScrollRef = useRef(0); + const meIdRef = useRef(null); + + // Append a line straight into the transcript (for /command output that isn't + // an engine event). + const emit = (entry: TranscriptEntry) => setItems((h) => withEntry(h, entry)); + + useEffect( + // The trailing newline gives every stream item a blank line after it. + () => subscribe((entry) => setItems((h) => withEntry(h, entry))), + [subscribe], + ); + useEffect(() => { + const timer = setInterval(tick, 120); + return () => clearInterval(timer); + }, []); + // Load the account's current builder-model pick for the footer (non-blocking). + useEffect(() => { + getMe() + .then((me) => { + meIdRef.current = me.id; + setCurrentModel(me.builder_model ?? null); + }) + .catch(() => {}); + }, []); + + // Persist a pick and reflect it in the footer. + const applyModel = async (pick: (typeof MODELS)[number]) => { + const orange = chalk.hex(BRAND_ORANGE); + try { + if ((pick.id ?? null) === currentModel) { + emit(chalk.dim(` already on ${pick.name}`)); + return; + } + let id = meIdRef.current; + if (!id) { + id = (await getMe()).id; + meIdRef.current = id; + } + await saveBuilderModel(id, pick.id); + setCurrentModel(pick.id); + emit( + pick.id === null + ? chalk.dim(" model reset — Base44 chooses per app") + : ` ${orange("●")} model set to ${chalk.bold(pick.name)}`, + ); + } catch (error) { + emit( + chalk.red( + ` /model: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + }; + + // `/model` alone opens the arrow-navigable picker; `/model ` switches + // straight away. + const runModelSlash = (arg: string) => { + if (!arg) { + const cur = MODELS.findIndex((m) => (m.id ?? null) === currentModel); + setPickerIndex(cur >= 0 ? cur : 0); + return; + } + try { + void applyModel(resolvePick(arg)); + } catch (error) { + emit( + chalk.red( + ` /model: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + } + }; + + const applyCard = (outcome: { + state: CardState | null; + submit?: { + action: "approved" | "rejected"; + input: Record; + }; + dismissed?: boolean; + }) => { + if (outcome.submit && card) { + engine.answer(card.pending, outcome.submit.action, outcome.submit.input); + } + if (outcome.dismissed && card) { + dismissedRef.current.add(card.pending.toolCallId); + } + setCard(outcome.state); + }; + + // Open the oldest unanswered card as soon as the agent parks one; close it + // when the backend no longer reports that call waiting. + const pending = engine.status().pending; + useEffect(() => { + if (card) { + if (!pending.some((p) => p.toolCallId === card.pending.toolCallId)) { + setCard(null); + } + return; + } + if (pickerIndex !== null) return; + const next = pending.find((p) => !dismissedRef.current.has(p.toolCallId)); + if (next) setCard(openCard(next)); + }, [pending, card, pickerIndex]); + + useInput((char, key) => { + // Model picker owns the keyboard while open: arrows move the selection, + // Enter commits, Esc/Ctrl-C cancels. Swallow everything else so it doesn't + // scroll the transcript or type into the (hidden) input. + if (pickerIndex !== null) { + if (key.upArrow) + setPickerIndex((i) => ((i ?? 0) - 1 + MODELS.length) % MODELS.length); + else if (key.downArrow) + setPickerIndex((i) => ((i ?? 0) + 1) % MODELS.length); + else if (key.return) { + const pick = MODELS[pickerIndex]; + setPickerIndex(null); + void applyModel(pick); + } else if (key.escape || (key.ctrl && char === "c")) { + setPickerIndex(null); + } + return; + } + // Tab reopens a card dismissed with Esc. + if (key.tab && !card) { + const next = pending.find((p) => dismissedRef.current.has(p.toolCallId)); + if (next) { + dismissedRef.current.delete(next.toolCallId); + setCard(openCard(next)); + } + return; + } + // A card owns the keyboard — except while it is capturing text, when the + // input box does and only Esc backs out. + if (card) { + const mapped: CardKey | null = key.upArrow + ? "up" + : key.downArrow + ? "down" + : key.return + ? "enter" + : key.escape + ? "escape" + : char === " " + ? "space" + : char === "y" || char === "n" || char === "s" + ? (char as CardKey) + : null; + if (card.typing && mapped !== "escape") return; // TextInput handles it + if (mapped) applyCard(cardKey(card, mapped)); + if (mapped || !card.typing) return; + } + if (key.ctrl && char === "c") { + if (input) setInput(""); + else exit(); + return; + } + if (key.ctrl && char === "d") { + exit(); + return; + } + if (key.ctrl && char === "o") { + setVerbose((v) => !v); + return; + } + // Wheel scrolling: alternate-scroll mode turns it into arrow keys. + if (key.upArrow) { + setScroll((s) => Math.min(s + 3, maxScrollRef.current)); + return; + } + if (key.downArrow) { + setScroll((s) => Math.max(0, s - 3)); + return; + } + if (key.pageUp) { + setScroll((s) => Math.min(s + 20, maxScrollRef.current)); + return; + } + if (key.pageDown) { + setScroll((s) => Math.max(0, s - 20)); + return; + } + // Esc stops the running turn (like the editor's stop button); when nothing + // is running it snaps the transcript back to live. + if (key.escape) { + if (engine.turnRunning()) engine.stopTurn(); + else setScroll(0); + } + }); + + const columns = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + const width = columns; // input box and picker span the terminal, like the transcript + const innerWidth = Math.max(10, width - 4); // input border + padding + const inputRows = Math.max(1, Math.ceil((input.length + 3) / innerWidth)); // +cursor cell + const pickerOpen = pickerIndex !== null; + // The bottom block is either the input box (inputRows + 2 border) or the model + // picker (title + one row per model + 2 border). +3 = status + model line + + // hint; +1 more for the footer links when present. + const cardOpen = card !== null && !card.typing; + const cardRows = card ? cardLines(card).length : 0; + const inputBlockHeight = pickerOpen + ? MODELS.length + 3 + : cardOpen + ? cardRows + 2 + : inputRows + 2 + (card?.typing ? 1 : 0); + const widgetHeight = inputBlockHeight + 3 + (footer.length ? 1 : 0); + const viewHeight = Math.max(3, rows - widgetHeight - 1); + + // Hard-wrapped physical lines of the whole transcript; the view is a + // window over them, pinned to the bottom unless the user scrolled. + const lines = items.flatMap((item) => + hardWrapAnsi( + `${renderEntry(item, { verbose, foldHint: "Ctrl+O to expand" })}\n`, + columns, + ), + ); + const maxScroll = Math.max(0, lines.length - viewHeight); + maxScrollRef.current = maxScroll; + const clamped = Math.min(scroll, maxScroll); + const end = lines.length - clamped; + const visible = lines.slice(Math.max(0, end - viewHeight), end); + + const scrollNote = + clamped > 0 ? chalk.yellow(` ↑ ${clamped} lines — Esc for live`) : ""; + // The status row must stay EXACTLY one row or the whole widget bounces — + // truncate it (and every transcript row) instead of letting them wrap. + const statusLine = hardWrapAnsi( + `${statusText(engine.status(), musingSeed)}${scrollNote}`, + Math.max(10, columns - 1), + )[0]; + + return ( + + + {visible.map((line, index) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: windowed slice re-renders wholesale each frame; position is the identity + + {line || " "} + + ))} + + {statusLine} + {pickerOpen ? ( + + + {chalk.bold("Pick a model")} + {chalk.dim(" ↑↓ move · Enter select · Esc cancel")} + + {MODELS.map((m, i) => { + const selected = i === pickerIndex; + const isCurrent = (m.id ?? null) === currentModel; + const label = `${selected ? "▸" : " "} ${isCurrent ? "●" : "○"} ${m.name}${m.note ? ` (${m.note})` : ""}`; + return ( + + {selected ? label : chalk.dim(label)} + + ); + })} + + ) : cardOpen && card ? ( + + {cardLines(card).map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: rows re-render wholesale per keystroke + + {line} + + ))} + + ) : ( + + {card?.typing === "secret" && ( + + {chalk.bold(card.pending.secrets?.[card.step]?.name ?? "secret")} + {chalk.dim(" — value is hidden · Enter to save · Esc to cancel")} + + )} + {card?.typing === "custom" && ( + + {chalk.bold(card.pending.questions?.[card.step]?.question ?? "")} + {chalk.dim( + " — your own answer · Enter to save · Esc to go back", + )} + + )} + + {"❯ "} + {/* TODO: ink-text-input only handles ←/→, backspace and Enter. Ink + already decodes Alt+←/→ (word jump), Alt+Backspace / Ctrl+W (delete + word), Ctrl+A/E (line start/end) and Ctrl+U/K (kill line) — replace + this with a small in-house line editor that honours them. */} + { + if (card?.typing) { + // Typed for the card, never for the agent: no echo, no transcript line. + applyCard(cardText(card, value)); + setInput(""); + return; + } + const trimmed = value.trim(); + if (trimmed === "/model" || trimmed.startsWith("/model ")) { + runModelSlash(trimmed.slice("/model".length).trim()); + } else if (trimmed) { + engine.submit(value); + } + setInput(""); + }} + /> + + + )} + {footer.length > 0 && ( + {` ${footer.join(chalk.dim(" · "))}`} + )} + + {` ${chalk.dim("model")} ${chalk.hex(BRAND_ORANGE)(displayName(currentModel))}`} + + + {pickerOpen + ? " ↑↓ to move · Enter to select · Esc to cancel" + : cardOpen + ? " the agent is waiting on you · answer above, or Esc for later" + : pending.length > 0 && !card + ? ` ⏸ ${pending[0].title} — Tab to answer · Ctrl+C to exit` + : engine.turnRunning() + ? " Esc to stop · type to queue · scroll to read · Ctrl+O to expand · Ctrl+C to exit" + : " Enter to send · /model to switch model · Esc for live · Ctrl+O to expand · Ctrl+C to exit"} + + + ); +} + +/** + * The Claude-Code-style interactive session, rendered with Ink: history goes + * permanently into scrollback via , while the bottom region — rule, + * footer links, status line with the live turn timer, input, hints — re-renders + * in place. Typing works mid-turn (the backend queues the message); Ctrl+C + * clears the input, then exits; turns keep running server-side after exit. + * TTY only — callers gate on interactivity. + */ + +/** The welcome header, Claude-Code style: the sun mark on the left, the title / + * account / cwd lines stacked to its right. No box. */ +function renderHeader(who: string, mode?: string): string { + const orange = chalk.hex(BRAND_ORANGE); + const cwd = process.cwd().replace(process.env.HOME ?? "", "~"); + const logo = logoRows(BRAND_ORANGE); + const logoW = LOGO_COLS; + const text = [ + `${orange.bold("Base44 Code")} ${chalk.dim(`v${packageJson.version}`)}`, + chalk.bold(who ? `Welcome back, ${who}!` : "Welcome!"), + chalk.dim(getBase44ApiUrl().replace(/^https:\/\//, "")), + chalk.dim(cwd), + ...(mode ? [`${orange("●")} ${chalk.dim(mode)}`] : []), + ]; + const height = Math.max(logo.length, text.length); + const textTop = Math.max(0, Math.floor((logo.length - text.length) / 2)); + const out: string[] = []; + for (let i = 0; i < height; i++) { + const left = i < logo.length ? logo[i] : " ".repeat(logoW); + const right = text[i - textTop] ?? ""; + out.push(` ${left} ${right}`.trimEnd()); + } + return out.join("\n"); +} + +async function currentUserName(): Promise { + try { + const auth = await readAuth(); + return auth.name || auth.email || ""; + } catch { + return ""; // Not logged in yet — the welcome stays generic. + } +} + +/** The Base44 Code welcome box — the session's first history item, so it + * scrolls away naturally like Claude Code's header does. */ +async function buildHeader(mode?: string): Promise { + return renderHeader(await currentUserName(), mode); +} + +export async function runInteractiveSession( + options: SessionOptions, +): Promise { + const sessionStartedAt = Date.now(); + const listeners = new Set<(entry: TranscriptEntry) => void>(); + const buffered: TranscriptEntry[] = []; + const onLine = (line: TranscriptEntry) => { + if (listeners.size === 0) { + buffered.push(line); + return; + } + for (const listener of listeners) listener(line); + }; + const subscribe = (listener: (entry: TranscriptEntry) => void) => { + listeners.add(listener); + if (buffered.length) { + for (const line of buffered.splice(0)) listener(line); + } + return () => listeners.delete(listener); + }; + + const engine = createSessionEngine({ + branchId: options.branchId, + awaitingTurnLabel: options.awaitingTurnLabel, + idleHint: options.idleHint, + onLine, + onTurnSettled: options.onTurnSettled, + }); + + // Fresh viewport, Claude-Code style: clear the visible screen (shell history + // stays in scrollback) and start at the TOP — the header renders first, and + // the dynamic region's fixed height bottom-justifies the input widget at the + // terminal's bottom, with the conversation filling the space between. + enterAltScreen(); + onLine(await buildHeader()); + + // Bracketed paste: the terminal wraps pastes in markers (and drops its + // multi-line paste warning); the stdin proxy flattens them to one line. + process.stdout.write("\x1b[?2004h"); + const stdinProxy = createPasteFriendlyStdin(process.stdin); + const app = render( + , + { exitOnCtrlC: false, stdin: stdinProxy }, + ); + + try { + await engine.start(options.primeFirstPoll); + if (options.initialMessage) engine.submit(options.initialMessage); + await app.waitUntilExit(); + } finally { + process.stdout.write("\x1b[?2004l"); + stdinProxy.cleanup(); + engine.stop(); + exitAltScreen(); + if (options.footer.length) { + process.stdout.write(`${options.footer.join(chalk.dim(" · "))}\n`); + } + const note = engine.turnRunning() + ? " — the running turn continues server-side (watch it in the editor)" + : ""; + process.stdout.write( + `${chalk.dim(`session ended · ${formatDuration(Date.now() - sessionStartedAt)}${note}`)}\n`, + ); + } +} + +interface GenesisAppConfig { + branchId?: string; + awaitingTurnLabel?: string; + onTurnSettled?: (info: TurnSettleInfo) => void | Promise; +} + +interface GenesisOptions { + /** Shown next to "ready" before the first prompt. */ + idleHint: string; + /** Busy label while `createApp` runs. */ + creatingLabel: string; + /** Live footer array — `createApp` pushes the links as they exist. */ + footer: string[]; + /** Short mode label rendered in the header (e.g. "Builder" / "Import"). */ + modeLabel?: string; + /** Turn the first prompt into an app; returns the wiring for the real + * engine, which takes over every later prompt. */ + createApp: ( + prompt: string, + emit: (line: string) => void, + ) => Promise; +} + +/** + * A session that starts BEFORE any app exists: the Base44 Code page opens + * with just the header and the input, and the first prompt creates the app + * (repo, directory, kickoff build) — then a real engine takes over, exactly + * as if the session had been opened on it. + */ +export async function runGenesisSession( + options: GenesisOptions, +): Promise { + const sessionStartedAt = Date.now(); + const listeners = new Set<(entry: TranscriptEntry) => void>(); + const buffered: TranscriptEntry[] = []; + const onLine = (line: TranscriptEntry) => { + if (listeners.size === 0) { + buffered.push(line); + return; + } + for (const listener of listeners) listener(line); + }; + const subscribe = (listener: (entry: TranscriptEntry) => void) => { + listeners.add(listener); + if (buffered.length) { + for (const line of buffered.splice(0)) listener(line); + } + return () => listeners.delete(listener); + }; + + let inner: SessionEngine | null = null; + let creating = false; + let creatingSince = 0; + const IDLE_STATUS: SessionStatus = { + phase: "idle", + idleHint: options.idleHint, + awaitingSince: 0, + turnStartedAt: null, + runningTool: null, + lastTurnMs: null, + lastTurnOk: true, + pending: [], + quietForMs: 0, + }; + const genesis: SessionEngine = { + async start() {}, + stop() { + inner?.stop(); + }, + stopTurn() { + // Only a real engine can stop a server turn; app creation isn't stoppable. + inner?.stopTurn(); + }, + submit(text: string) { + if (inner) { + inner.submit(text); + return; + } + if (creating) { + onLine(chalk.dim("· hold on — still creating the app")); + return; + } + creating = true; + creatingSince = Date.now(); + onLine(`${chalk.cyan("❯")} ${chalk.bold(text)}`); + options + .createApp(text, onLine) + .then(async (config) => { + const engine = createSessionEngine({ + branchId: config.branchId, + awaitingTurnLabel: config.awaitingTurnLabel, + onLine, + onTurnSettled: config.onTurnSettled, + }); + await engine.start(false); + inner = engine; + }) + .catch(async (error: unknown) => { + creating = false; + const message = + error instanceof Error ? error.message : String(error); + onLine(chalk.red(`✗ create failed: ${message}`)); + // A stale GitHub connection 401s while the create verifies repo + // access. Hand back a reconnect link — a plain retry just 401s again. + if (isGithubUserTokenError(error)) { + const link = await startGithubReauth().catch(() => null); + onLine( + chalk.yellow( + "GitHub authorization expired — reconnect, then try again:", + ), + ); + onLine( + link + ? terminalLink("Reconnect GitHub", link) + : "Open Base44 → GitHub settings to reconnect your account.", + ); + } + }); + }, + status(): SessionStatus { + if (inner) return inner.status(); + if (creating) { + return { + ...IDLE_STATUS, + phase: "awaiting", + awaitingLabel: options.creatingLabel, + awaitingSince: creatingSince, + }; + } + return IDLE_STATUS; + }, + answer(p, action, input) { + // Nothing can be pending before the app exists; after, the real engine has it. + inner?.answer(p, action, input); + }, + turnRunning() { + return inner?.turnRunning() ?? creating; + }, + }; + + enterAltScreen(); + onLine(await buildHeader(options.modeLabel)); + + process.stdout.write("\x1b[?2004h"); + const stdinProxy = createPasteFriendlyStdin(process.stdin); + const app = render( + , + { exitOnCtrlC: false, stdin: stdinProxy }, + ); + + try { + await app.waitUntilExit(); + } finally { + process.stdout.write("\x1b[?2004l"); + stdinProxy.cleanup(); + genesis.stop(); + exitAltScreen(); + if (options.footer.length) { + process.stdout.write(`${options.footer.join(chalk.dim(" · "))}\n`); + } + const note = genesis.turnRunning() + ? " — the running turn continues server-side (watch it in the editor)" + : ""; + process.stdout.write( + `${chalk.dim(`session ended · ${formatDuration(Date.now() - sessionStartedAt)}${note}`)}\n`, + ); + } +} diff --git a/packages/cli/src/cli/commands/sandbox/index.ts b/packages/cli/src/cli/commands/sandbox/index.ts index 3b8edb3e5..587764ef6 100644 --- a/packages/cli/src/cli/commands/sandbox/index.ts +++ b/packages/cli/src/cli/commands/sandbox/index.ts @@ -3,6 +3,7 @@ import { getSandboxCheckpointCommand } from "./checkpoint.js"; import { getSandboxEditFileCommand } from "./edit-file.js"; import { getSandboxGrepCommand } from "./grep.js"; import { getSandboxListDirectoryCommand } from "./list-directory.js"; +import { getSandboxPreviewCommand } from "./preview.js"; import { getSandboxReadFileCommand } from "./read-file.js"; import { getSandboxRunCommandCommand } from "./run-command.js"; import { getSandboxWriteFileCommand } from "./write-file.js"; @@ -16,5 +17,6 @@ export function getSandboxCommand(): Command { .addCommand(getSandboxEditFileCommand()) .addCommand(getSandboxGrepCommand()) .addCommand(getSandboxRunCommandCommand()) - .addCommand(getSandboxCheckpointCommand()); + .addCommand(getSandboxCheckpointCommand()) + .addCommand(getSandboxPreviewCommand()); } diff --git a/packages/cli/src/cli/commands/sandbox/preview.ts b/packages/cli/src/cli/commands/sandbox/preview.ts new file mode 100644 index 000000000..5f1bb2d61 --- /dev/null +++ b/packages/cli/src/cli/commands/sandbox/preview.ts @@ -0,0 +1,20 @@ +import type { CLIContext, RunCommandResult } from "@/cli/types.js"; +import { Base44Command } from "@/cli/utils/index.js"; +import { getPreviewUrl } from "@/core/resources/apps/api.js"; + +async function previewAction(ctx: CLIContext): Promise { + const url = await ctx.runTask( + "Resolving preview URL (boots the sandbox if needed)", + () => getPreviewUrl(), + ); + if (ctx.jsonMode) + return { stdout: `${JSON.stringify({ preview_url: url })}\n` }; + ctx.log.message(url); + return { outroMessage: "Preview is live." }; +} + +export function getSandboxPreviewCommand(): Base44Command { + const command = new Base44Command("preview"); + command.description("Print the app's live preview URL").action(previewAction); + return command; +} diff --git a/packages/cli/src/cli/program.ts b/packages/cli/src/cli/program.ts index 070dfc749..5980811c6 100644 --- a/packages/cli/src/cli/program.ts +++ b/packages/cli/src/cli/program.ts @@ -7,6 +7,8 @@ import { getLoginCommand } from "@/cli/commands/auth/login.js"; import { getLogoutCommand } from "@/cli/commands/auth/logout.js"; import { getWhoamiCommand } from "@/cli/commands/auth/whoami.js"; import { getBranchesCommand } from "@/cli/commands/branches/index.js"; +import { getBuilderCommand } from "@/cli/commands/builder/index.js"; +import { getCodeCommand } from "@/cli/commands/code/index.js"; import { getConnectorsCommand } from "@/cli/commands/connectors/index.js"; import { getDashboardCommand } from "@/cli/commands/dashboard/index.js"; import { getEntitiesPushCommand } from "@/cli/commands/entities/push.js"; @@ -112,6 +114,10 @@ export function createProgram(context: CLIContext): Command { program.addCommand(getSandboxCommand()); program.addCommand(getBranchesCommand()); + // Register agent-build commands (non-interactive atoms + interactive session) + program.addCommand(getBuilderCommand()); + program.addCommand(getCodeCommand()); + // Register auth config commands program.addCommand(getAuthCommand()); diff --git a/packages/cli/src/cli/telemetry/commander-hooks.ts b/packages/cli/src/cli/telemetry/commander-hooks.ts index 7c95a5f6a..e4239f1da 100644 --- a/packages/cli/src/cli/telemetry/commander-hooks.ts +++ b/packages/cli/src/cli/telemetry/commander-hooks.ts @@ -21,6 +21,20 @@ function getFullCommandName(command: Command): string { return parts.join(" "); } +// Option values that are credentials never leave the machine, even on a crash. +const SENSITIVE_OPTION = /secret|token|password|launch|instance|key$/i; + +function redactSensitiveOptions( + options: Record, +): Record { + return Object.fromEntries( + Object.entries(options).map(([k, v]) => [ + k, + SENSITIVE_OPTION.test(k) && v != null && v !== false ? "[redacted]" : v, + ]), + ); +} + export function addCommandInfoToErrorReporter( program: Command, errorReporter: ErrorReporter, @@ -32,7 +46,7 @@ export function addCommandInfoToErrorReporter( command: { name: fullCommandName, args: actionCommand.args, - options: actionCommand.opts(), + options: redactSensitiveOptions(actionCommand.opts()), }, }); }); diff --git a/packages/cli/src/core/clients/base44-client.ts b/packages/cli/src/core/clients/base44-client.ts index a50e52fac..bee474543 100644 --- a/packages/cli/src/core/clients/base44-client.ts +++ b/packages/cli/src/core/clients/base44-client.ts @@ -32,6 +32,12 @@ async function captureRequestBody( if (request.body == null) { return; } + // Callers sending credentials (secret values, a signed Wix instance) opt out + // of body capture so a failed request never carries them into telemetry. + if (options.context.__redactBody) { + options.context.__requestBody = "[redacted]"; + return; + } try { const cloned = request.clone(); const text = await cloned.text(); @@ -101,6 +107,10 @@ export const base44Client = ky.create({ beforeRequest: [ (request) => { request.headers.set("X-Request-ID", randomUUID()); + // Honor the account's saved builder-model pick (`base44 builder model`); + // without it the backend auto-selects. Safe unconditionally: with no + // saved pick it falls back to the app default — the web editor's contract. + request.headers.set("X-Builder-Model-Selection", "user-v1"); }, captureRequestBody, async (request) => { diff --git a/packages/cli/src/core/model.ts b/packages/cli/src/core/model.ts new file mode 100644 index 000000000..db605eced --- /dev/null +++ b/packages/cli/src/core/model.ts @@ -0,0 +1,98 @@ +import { HTTPError } from "ky"; +import { base44Client } from "./clients/index.js"; +import { ApiError, InvalidInputError } from "./errors.js"; + +/** + * Main customer-facing builder models, mirroring the web model picker's customer + * set (modelPickerRegistry). Name shown to the user -> backend picker id; the + * backend validates the id and the workspace entitlement per turn, so a pick it + * rejects surfaces as a clear error rather than a silent fallback. Notes are + * the web picker's own copy. "Automatic" clears the pick (builder_model = null); + * `default` and `auto` are accepted as typed aliases for it. + */ +export const MODELS: { + name: string; + id: string | null; + note?: string; + aliases?: string[]; +}[] = [ + { + name: "Automatic", + id: null, + note: "matched with the best model", + aliases: ["default", "auto"], + }, + { name: "Opus 5", id: "claude_opus_5" }, + { name: "Sonnet 5", id: "claude-sonnet-5" }, + { name: "Fable 5", id: "claude_fable_5", note: "uses more credits" }, + { name: "GPT-5.6 Sol", id: "gpt_5_6_sol" }, + { + name: "Gemini 3.8 Flash", + id: "gemini_3_8_flash", + note: "fast responses for everyday tasks", + }, + { name: "Base 1", id: "base1" }, +]; + +/** Fold a name or id to a comparable key: lowercase, drop every non-alphanumeric + * so "Opus 5", "opus-5", "opus_5" and "claude_opus_5" all match sensibly. */ +const fold = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]/g, ""); + +export function resolvePick(input: string): (typeof MODELS)[number] { + const key = fold(input); + const byExact = MODELS.find( + (m) => + fold(m.name) === key || + (m.id && fold(m.id) === key) || + m.aliases?.some((a) => fold(a) === key), + ); + if (byExact) return byExact; + // Loose contains: "opus" -> Opus 5, "gemini" -> Gemini 3.8 Flash. + const byContains = MODELS.filter( + (m) => fold(m.name).includes(key) || (m.id && fold(m.id).includes(key)), + ); + if (byContains.length === 1) return byContains[0]; + const names = MODELS.map((m) => m.name).join(", "); + throw new InvalidInputError( + byContains.length > 1 + ? `"${input}" is ambiguous — matches ${byContains.map((m) => m.name).join(", ")}.` + : `Unknown model "${input}". Choose one of: ${names}.`, + ); +} + +interface MeResponse { + id: string; + builder_model?: string | null; +} + +export async function getMe(): Promise { + try { + return await base44Client.get("api/auth/me").json(); + } catch (error) { + throw await ApiError.fromHttpError(error, "reading your account"); + } +} + +export async function saveBuilderModel( + userId: string, + modelId: string | null, +): Promise { + try { + await base44Client.post(`api/auth/${userId}/update-user`, { + json: { builder_model: modelId }, + }); + } catch (error) { + // Model selection is enabled per account server-side; a 400 here means the + // account lacks it (or the model isn't runnable in this workspace). + if (error instanceof HTTPError && error.response.status === 400) { + throw new InvalidInputError( + "This account can't pick a builder model yet, or the model isn't available in this workspace. Ask your workspace admin to enable model selection.", + ); + } + throw await ApiError.fromHttpError(error, "saving your model choice"); + } +} + +/** Display name for a stored builder_model id (may be one the CLI doesn't list). */ +export const displayName = (id: string | null | undefined): string => + MODELS.find((m) => m.id === id)?.name ?? id ?? "Automatic"; diff --git a/packages/cli/src/core/resources/apps/api.ts b/packages/cli/src/core/resources/apps/api.ts new file mode 100644 index 000000000..48af26b4e --- /dev/null +++ b/packages/cli/src/core/resources/apps/api.ts @@ -0,0 +1,366 @@ +import type { KyResponse } from "ky"; +import { z } from "zod"; +import { base44Client, getAppClient } from "@/core/clients/index.js"; +import { ApiError, SchemaValidationError } from "@/core/errors.js"; +import { listBranches } from "@/core/resources/branch/api.js"; + +const CreatedAppSchema = z.object({ + id: z.string().min(1), + name: z.string().nullish(), + imported_repo_url: z.string().nullish(), +}); +type CreatedApp = z.infer; + +const WixCreatedAppSchema = z.object({ + app_id: z.string().min(1), + client_creation_id: z.string().min(1), +}); + +const AppStateSchema = z.object({ + id: z.string(), + app_type: z.string().nullish(), + is_managed_source_code: z.boolean().nullish(), + imported_repo_url: z.string().nullish(), + status: z + .object({ + state: z.string().nullish(), + message: z.string().nullish(), + }) + .nullish(), +}); +export type AppState = z.infer; + +const ChatTurnSchema = z.object({ + queued: z.boolean().optional(), + status: z + .object({ + state: z.string().nullish(), + message: z.string().nullish(), + error_source: z.string().nullish(), + }) + .nullish(), + conversation: z + .object({ + messages: z + .array( + z.object({ + role: z.string().nullish(), + content: z.unknown().nullish(), + }), + ) + .nullish(), + }) + .nullish(), +}); +export type ChatTurn = z.infer; + +const PreviewUrlSchema = z.object({ + preview_url: z.string().min(1), +}); + +const ConversationMessageSchema = z.object({ + id: z.string(), + role: z.string(), + hidden: z.boolean().nullish(), + outcome: z.unknown().nullish(), + content: z.unknown().nullish(), + reasoning: z.object({ content: z.string().nullish() }).nullish(), + tool_calls: z + .array( + z.object({ + id: z.string(), + name: z.string(), + arguments_string: z.string().nullish(), + status: z.string().nullish(), + results: z.unknown().nullish(), + /** Why the call is parked: approval, choice, or input. */ + waiting_on: z.object({ kind: z.string().nullish() }).nullish(), + }), + ) + .nullish(), +}); +export type ConversationMessage = z.infer; + +const FullConversationSchema = z.object({ + messages: z.array(ConversationMessageSchema).default([]), +}); + +const OAuthInitiateSchema = z.object({ authorization_url: z.string().min(1) }); + +function parseOrThrow( + schema: z.ZodType, + payload: unknown, + what: string, +): T { + const result = schema.safeParse(payload); + if (!result.success) { + throw new SchemaValidationError( + `Invalid ${what} response from server`, + result.error, + ); + } + return result.data; +} + +function branchScope(branchId?: string): Record { + return branchId ? { branch_id: branchId } : {}; +} + +interface CreateAppOptions { + appName?: string; + prompt?: string; + organizationId?: string; +} + +interface WixLaunchOptions { + prompt: string; + /** The signed Wix instance from the launch URL's fragment. */ + signedInstance: string; + /** The companion OAuth app's client id, when the launch carried one. */ + wixClientId?: string; +} + +/** Create an app through the Wix launch route: the backend verifies the signed + * instance, connects the Wix connector BEFORE the first turn, and starts that + * turn from the prompt — the one thing `POST /api/apps` cannot do. */ +export async function createWixLaunchedApp( + options: WixLaunchOptions, +): Promise { + let response: KyResponse; + try { + response = await base44Client.post("api/wix/create-app", { + timeout: false, + context: { __redactBody: true }, // carries the signed instance + json: { + prompt: options.prompt, + signed_instance: options.signedInstance, + ...(options.wixClientId ? { wix_client_id: options.wixClientId } : {}), + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating app via Wix launch"); + } + const created = parseOrThrow( + WixCreatedAppSchema, + await response.json(), + "app", + ); + return { id: created.app_id, client_creation_id: created.client_creation_id }; +} + +/** Create a standard Base44 app (builder agent + React template). No + * app_type is sent — the backend defaults to user_app. A prompt auto-starts + * the first turn in the background; poll the conversation to follow it. */ +export async function createApp( + options: CreateAppOptions, +): Promise { + let response: KyResponse; + try { + response = await base44Client.post("api/apps", { + timeout: false, + json: { + ...(options.appName ? { name: options.appName } : {}), + ...(options.organizationId + ? { organization_id: options.organizationId } + : {}), + ...(options.prompt + ? { initial_message: { content: options.prompt } } + : {}), + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "creating app"); + } + return parseOrThrow(CreatedAppSchema, await response.json(), "app"); +} + +export type ImportSourceMode = "direct" | "fork" | "copy"; + +interface CreateImportedAppOptions { + appName: string; + repoUrl: string; + sourceMode: ImportSourceMode; + /** Name for the new GitHub repository when forking/copying. */ + newRepoName?: string; + /** Import a specific branch of the source repo. */ + branch?: string; + prompt?: string; +} + +/** Create an app over an existing GitHub repository. Forking/copying runs on + * the caller's GitHub token, so this can outlive ky's default timeout. */ +export async function createImportedApp( + options: CreateImportedAppOptions, +): Promise { + let response: KyResponse; + try { + response = await base44Client.post("api/apps", { + timeout: false, + json: { + app_type: "imported_app", + name: options.appName, + imported_source_mode: options.sourceMode, + imported_repo_url: options.repoUrl, + ...(options.newRepoName + ? { imported_new_repo_name: options.newRepoName } + : {}), + ...(options.branch ? { imported_branch: options.branch } : {}), + ...(options.prompt + ? { initial_message: { content: options.prompt } } + : {}), + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "importing repository"); + } + return parseOrThrow(CreatedAppSchema, await response.json(), "app"); +} + +/** Authorize URL for a fresh account-only GitHub OAuth. Recovers an expired + * connection: import verifies repo access with the caller's token, and GitHub + * 401s a stale one — re-authorizing fixes it, retrying does not. */ +export async function startGithubReauth(): Promise { + const response = await base44Client.post( + "api/github/oauth/initiate?skip_installation=true", + ); + return parseOrThrow( + OAuthInitiateSchema, + await response.json(), + "github oauth initiate", + ).authorization_url; +} + +/** GitHub rejecting the caller's OAuth token — a 401 against api.github.com. */ +export function isGithubUserTokenError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + /api\.github\.com/i.test(message) && /\b401\b|unauthorized/i.test(message) + ); +} + +export async function getAppState(appId: string): Promise { + let response: KyResponse; + try { + response = await base44Client.get(`api/apps/${appId}`, { + searchParams: { + fields: "id,status,app_type,is_managed_source_code,imported_repo_url", + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "reading app status"); + } + return parseOrThrow(AppStateSchema, await response.json(), "app status"); +} + +export type ToolCallAction = "approved" | "rejected"; + +/** Answer a tool call the agent parked with `waiting_for_user_input`. The + * backend resumes the tool and starts a new turn; like `sendTurn`, the request + * stays open until that turn finishes. `input` is the tool's `extra_user_input` + * (e.g. `{answers}`, `{secrets}`, `{approved_permission_keys}`, or `{}`). The + * body may carry secret values, so it is never captured for telemetry. */ +export async function answerToolCall( + options: { + toolCallId: string; + messageId?: string; + action: ToolCallAction; + input?: Record; + }, + branchId?: string, +): Promise { + let response: KyResponse; + try { + response = await getAppClient().post("chat/submit-tool-call-input", { + timeout: false, + context: { __redactBody: true }, + searchParams: branchScope(branchId), + json: { + tool_call_id: options.toolCallId, + action: options.action, + extra_user_input: options.input ?? {}, + ...(options.messageId ? { message_id: options.messageId } : {}), + }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "answering the agent"); + } + return parseOrThrow(ChatTurnSchema, await response.json(), "chat turn"); +} + +/** One agent turn. The request stays open until the turn finishes. */ +export async function sendTurn( + content: string, + branchId?: string, +): Promise { + let response: KyResponse; + try { + response = await getAppClient().post("chat/message", { + timeout: false, + searchParams: { + conversation_messages: "current_turn", + ...branchScope(branchId), + }, + json: { content }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "sending message"); + } + return parseOrThrow(ChatTurnSchema, await response.json(), "chat turn"); +} + +/** Server-side stop of the running turn on the given branch. */ +export async function stopTurn(branchId?: string): Promise { + try { + await getAppClient().post("chat/stop", { + searchParams: branchScope(branchId), + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "stopping the turn"); + } +} + +export async function getFullConversation( + limit: number, + branchId?: string, +): Promise { + let response: KyResponse; + try { + response = await getAppClient().get("chat/full-conversation", { + timeout: 30_000, + searchParams: { limit: String(limit), ...branchScope(branchId) }, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "reading the conversation"); + } + return parseOrThrow( + FullConversationSchema, + await response.json(), + "conversation", + ).messages; +} + +/** The branch the app's work happens on, when unambiguous. A request with no + * branch targets main; an imported app works on its single setup branch, so + * unscoped messages would land on the wrong line. */ +export async function resolveActiveBranchId(): Promise { + const branches = await listBranches(); + return branches.length === 1 ? branches[0].id : undefined; +} + +/** Live preview URL. Rehydrates a cold sandbox first, so it can take a while. */ +export async function getPreviewUrl(): Promise { + let response: KyResponse; + try { + response = await getAppClient().get("sandbox/preview-url", { + timeout: false, + }); + } catch (error) { + throw await ApiError.fromHttpError(error, "fetching preview URL"); + } + const url = parseOrThrow( + PreviewUrlSchema, + await response.json(), + "preview URL", + ).preview_url; + return /^https?:\/\//.test(url) ? url : `https://${url}`; +} diff --git a/packages/cli/src/core/resources/apps/pending.ts b/packages/cli/src/core/resources/apps/pending.ts new file mode 100644 index 000000000..8d48a0669 --- /dev/null +++ b/packages/cli/src/core/resources/apps/pending.ts @@ -0,0 +1,264 @@ +import type { ConversationMessage } from "@/core/resources/apps/api.js"; + +/** + * What the agent is waiting on. A tool call parked with + * `status: waiting_for_user_input` carries its request in its own arguments + * (sent in full for a waiting call); guard-parked calls carry a verdict in + * `results`. This turns those into one shape the session can render and the + * atoms can print, per kind: approval (yes/no), choice (questions with + * options), input (a form: secrets), permissions (checkbox rows). + */ +export type PendingKind = + | "approval" + | "choice" + | "secrets" + | "permissions" + | "browser"; + +interface PendingOption { + label: string; + description?: string; +} + +export interface PendingQuestion { + question: string; + description?: string; + options: PendingOption[]; + multiSelect: boolean; +} + +export interface PendingSecret { + name: string; + description?: string; +} + +export interface PendingPermission { + /** The key the answer names: entity: · backend_function: · app_user_connector:. */ + key: string; + label: string; + reason?: string; +} + +export interface PendingInput { + toolCallId: string; + messageId: string; + tool: string; + kind: PendingKind; + /** One line saying what is being asked. */ + title: string; + /** Supporting text: the summary, the guard's reason, the permission request's reason. */ + detail?: string; + questions?: PendingQuestion[]; + secrets?: PendingSecret[]; + permissions?: PendingPermission[]; +} + +const CHOICE_TOOLS = new Set([ + "ask_clarifying_questions", + "ask_plan_questions", +]); +const SECRET_TOOLS = new Set(["set_secrets"]); +const PERMISSION_TOOLS = new Set(["request_agent_tool_permissions"]); +/** Approval only after a step the web runs (OAuth popup, payments form). */ +const BROWSER_TOOLS = new Set([ + "connect_github_account", + "request_oauth_authorization", + "register_workspace_connector", + "configure_psp_credentials", + "plaid_connect", +]); + +const str = (v: unknown): string | undefined => + typeof v === "string" && v.trim() ? v.trim() : undefined; + +function parseArgs(raw: string | null | undefined): Record { + try { + const parsed: unknown = JSON.parse(raw ?? ""); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +function questionsFrom(args: Record): PendingQuestion[] { + const raw = Array.isArray(args.questions) ? args.questions : []; + return raw.flatMap((q) => { + if (!q || typeof q !== "object") return []; + const item = q as Record; + const question = str(item.question); + if (!question) return []; + const options = (Array.isArray(item.options) ? item.options : []).flatMap( + (o) => { + const opt = o as Record; + const label = str(opt?.label); + return label ? [{ label, description: str(opt.description) }] : []; + }, + ); + return [ + { + question, + description: str(item.description), + options, + multiSelect: item.multi_select === true, + }, + ]; + }); +} + +function secretsFrom(args: Record): PendingSecret[] { + const raw = Array.isArray(args.secrets_schema) ? args.secrets_schema : []; + return raw.flatMap((s) => { + const item = s as Record; + const name = str(item?.secretName) ?? str(item?.name); + return name ? [{ name, description: str(item.description) }] : []; + }); +} + +/** Same key the backend derives (`AgentToolPermissionRequest.key`). */ +export function permissionKey(row: Record): string | null { + switch (row.type) { + case "entity": + return row.entity_name ? `entity:${row.entity_name}` : null; + case "backend_function": + return row.function_name ? `backend_function:${row.function_name}` : null; + case "app_user_connector": + return row.connector_id ? `app_user_connector:${row.connector_id}` : null; + default: + return null; + } +} + +function permissionsFrom(args: Record): PendingPermission[] { + const raw = Array.isArray(args.requested_permissions) + ? args.requested_permissions + : []; + return raw.flatMap((r) => { + const row = r as Record; + const key = permissionKey(row); + if (!key) return []; + const ops = Array.isArray(row.allowed_operations) + ? ` (${(row.allowed_operations as unknown[]).join(", ")})` + : ""; + const target = + str(row.entity_name) ?? + str(row.function_name) ?? + str(row.connector_name) ?? + key; + return [ + { key, label: `${row.type}: ${target}${ops}`, reason: str(row.reason) }, + ]; + }); +} + +function guardFrom( + results: unknown, +): { title: string; detail?: string } | null { + const value = + typeof results === "string" + ? (() => { + try { + return JSON.parse(results) as unknown; + } catch { + return null; + } + })() + : results; + if (!value || typeof value !== "object") return null; + const g = value as Record; + if (!str(g.guard)) return null; + return { title: `${g.guard}: needs your approval`, detail: str(g.reason) }; +} + +function humanize(tool: string): string { + return tool.replace(/_/g, " "); +} + +/** Every parked tool call in the conversation, oldest first. */ +export function pendingInputs(messages: ConversationMessage[]): PendingInput[] { + const out: PendingInput[] = []; + for (const message of messages) { + for (const call of message.tool_calls ?? []) { + if (call.status !== "waiting_for_user_input") continue; + const args = parseArgs(call.arguments_string); + const base = { + toolCallId: call.id, + messageId: message.id, + tool: call.name, + }; + const summary = str(args.summary); + if (CHOICE_TOOLS.has(call.name)) { + out.push({ + ...base, + kind: "choice", + title: summary ?? "The agent has a few questions", + questions: questionsFrom(args), + }); + } else if (SECRET_TOOLS.has(call.name)) { + out.push({ + ...base, + kind: "secrets", + title: summary ?? "The agent needs secrets", + secrets: secretsFrom(args), + }); + } else if (PERMISSION_TOOLS.has(call.name)) { + out.push({ + ...base, + kind: "permissions", + title: summary ?? "Grant the app's agent these permissions?", + detail: str(args.reason), + permissions: permissionsFrom(args), + }); + } else if (BROWSER_TOOLS.has(call.name)) { + out.push({ + ...base, + kind: "browser", + title: summary ?? humanize(call.name), + detail: str(args.reason), + }); + } else { + const guard = guardFrom(call.results); + const integration = str(args.integration_type); + out.push({ + ...base, + kind: "approval", + title: + guard?.title ?? + (integration + ? `Enable ${integration}?` + : (summary ?? `${humanize(call.name)}?`)), + detail: guard?.detail ?? (integration ? summary : undefined), + }); + } + } + } + return out; +} + +export interface ChoiceSelection { + /** Selected option labels; several only for multi-select questions. */ + labels: string[]; + /** Free text for "something else". */ + customText?: string; +} + +/** `extra_user_input` for a choice card, in the web client's shape. */ +export function choiceAnswers( + questions: PendingQuestion[], + selections: ChoiceSelection[], +): { answers: Record[] } { + const answers = questions.flatMap((q, index) => { + const sel = selections[index]; + if (!sel || (sel.labels.length === 0 && !sel.customText)) return []; + const answer: Record = { question_index: index }; + if (q.multiSelect) { + if (sel.labels.length) answer.selected_labels = sel.labels; + } else if (sel.labels[0]) { + answer.selected_label = sel.labels[0]; + } + if (sel.customText) answer.custom_text = sel.customText; + return [answer]; + }); + return { answers }; +} diff --git a/packages/cli/src/core/resources/apps/stream.ts b/packages/cli/src/core/resources/apps/stream.ts new file mode 100644 index 000000000..fc431b8f1 --- /dev/null +++ b/packages/cli/src/core/resources/apps/stream.ts @@ -0,0 +1,370 @@ +import type { ConversationMessage } from "@/core/resources/apps/api.js"; +import { getFullConversation } from "@/core/resources/apps/api.js"; + +export type StreamEvent = + | { kind: "thinking"; text: string } + | { kind: "text"; text: string } + | { kind: "waiting"; id: string; name: string; label: string } + | { + kind: "tool_start"; + id: string; + name: string; + /** The tool's human title (its `summary` argument), "" when absent. */ + label: string; + /** The salient argument: the command, the path, the PR title. */ + summary: string; + } + | { + kind: "tool_end"; + id: string; + name: string; + label: string; + summary: string; + ok: boolean; + /** The tool's full result: the string it returned, or JSON for anything else. */ + result: string; + /** Parsed arguments; null when the wire truncated the JSON. */ + args?: Record | null; + }; + +interface AnnouncedTool { + label: string; + summary: string; +} + +/** An announced call plus its parsed arguments, kept for the result line. */ +type AnnouncedCall = AnnouncedTool & { args: Record | null }; + +interface MessageProgress { + contentLength: number; + reasoningLength: number; + announcedTools: Map< + string, + AnnouncedTool & { args: Record | null } + >; + settledTools: Set; + waitingNotified: Set; +} + +interface StreamState { + perMessage: Map; +} + +export function newStreamState(): StreamState { + return { perMessage: new Map() }; +} + +const TOOL_SETTLED = new Set(["success", "error", "stopped"]); + +/** The whole result as text: strings verbatim, anything else as JSON. */ +function fullText(value: unknown): string { + if (typeof value === "string") return value.trimEnd(); + if (value == null) return ""; + return JSON.stringify(value); +} + +/** Parsed tool arguments, or null when the wire cut the JSON short. */ +function toolArgs( + argumentsString: string | null | undefined, +): Record | null { + try { + const parsed: unknown = JSON.parse(argumentsString ?? ""); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +function oneLine(value: unknown, max: number): string { + const text = + typeof value === "string" + ? value + : value == null + ? "" + : JSON.stringify(value); + const flat = text.replace(/\s+/g, " ").trim(); + return flat.length > max ? `${flat.slice(0, max)}…` : flat; +} + +const SALIENT_KEYS = ["command", "path", "file_path", "title", "find"]; + +/** Pull one key's value out of TRUNCATED arguments JSON (the wire cuts big + * payloads mid-string, so JSON.parse fails while the key we want survived). */ +function salvageKey(raw: string, keys: string[]): string | undefined { + for (const key of keys) { + const match = raw.match( + new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`), + ); + if (match?.[1]) return match[1].replace(/\\(.)/g, "$1"); + } + return undefined; +} + +/** Both faces of a tool call: the human title the model wrote (`summary` + * argument — what the editor shows) and the salient raw argument (the + * command, the path, the PR title). */ +export function toolMeta( + name: string, + argumentsString: string | null | undefined, +): AnnouncedTool { + const raw = argumentsString ?? ""; + let args: Record = {}; + try { + const parsed: unknown = JSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + args = parsed as Record; + } + } catch { + return { + label: oneLine(salvageKey(raw, ["summary"]) ?? "", 90), + summary: oneLine(salvageKey(raw, SALIENT_KEYS) ?? raw, 90), + }; + } + const pick = (key: string): string | undefined => + typeof args[key] === "string" && (args[key] as string).trim() + ? (args[key] as string) + : undefined; + const salient: Record = { + run_shell_command: pick("command"), + read_repo_file: pick("path") ?? pick("file_path"), + write_repo_file: pick("path") ?? pick("file_path"), + edit_repo_file: pick("path") ?? pick("file_path"), + read_file: pick("file_path") ?? pick("path"), + write_file: pick("file_path") ?? pick("path"), + find_replace: pick("file_path") ?? pick("path"), + delete_file: pick("file_path") ?? pick("path"), + create_pull_request: pick("title"), + reload_preview: "", + }; + const summary = + salient[name] ?? + Object.entries(args).find( + ([key, v]) => + key !== "summary" && typeof v === "string" && v.trim().length > 0, + )?.[1] ?? + ""; + return { + label: oneLine(pick("summary") ?? "", 90), + summary: oneLine(summary as string, 90), + }; +} + +/** The tool `summary` argument carries two tenses: "Checking X | Checked X". + * Pick the one matching the moment; a single-form label serves both. */ +function labelTense(label: string, tense: "running" | "done"): string { + const parts = label.split(/\s*\|\s*/); + if (parts.length < 2) return label; + return tense === "running" ? parts[0] : parts[1]; +} + +function progressFor(state: StreamState, id: string): MessageProgress { + let progress = state.perMessage.get(id); + if (!progress) { + progress = { + contentLength: 0, + reasoningLength: 0, + announcedTools: new Map(), + settledTools: new Set(), + waitingNotified: new Set(), + }; + state.perMessage.set(id, progress); + } + return progress; +} + +/** + * Diff a fresh conversation snapshot against what was already emitted and + * return the new events. Mutates `state`; otherwise pure — no I/O — so the + * streaming rules are unit-testable. + */ +export function diffConversation( + state: StreamState, + messages: ConversationMessage[], +): StreamEvent[] { + const events: StreamEvent[] = []; + for (const message of messages) { + if (message.role !== "assistant" || message.hidden) continue; + const progress = progressFor(state, message.id); + + const reasoning = message.reasoning?.content ?? ""; + if (reasoning.length > progress.reasoningLength) { + const delta = reasoning.slice(progress.reasoningLength).trim(); + if (delta) events.push({ kind: "thinking", text: oneLine(delta, 300) }); + progress.reasoningLength = reasoning.length; + } + + if ( + typeof message.content === "string" && + message.content.length > progress.contentLength + ) { + const delta = message.content.slice(progress.contentLength).trim(); + if (delta) events.push({ kind: "text", text: delta }); + progress.contentLength = message.content.length; + } + + for (const tool of message.tool_calls ?? []) { + if (!progress.announcedTools.has(tool.id)) { + progress.announcedTools.set(tool.id, { + ...toolMeta(tool.name, tool.arguments_string), + args: toolArgs(tool.arguments_string), + }); + const meta = progress.announcedTools.get(tool.id) as AnnouncedCall; + events.push({ + kind: "tool_start", + id: tool.id, + name: tool.name, + label: labelTense(meta.label, "running"), + summary: meta.summary, + }); + } + const status = tool.status ?? "running"; + if ( + status === "waiting_for_user_input" && + !progress.waitingNotified.has(tool.id) + ) { + progress.waitingNotified.add(tool.id); + const meta = progress.announcedTools.get(tool.id) as AnnouncedCall; + events.push({ + kind: "waiting", + id: tool.id, + name: tool.name, + label: labelTense(meta.label, "running"), + }); + } + if (TOOL_SETTLED.has(status) && !progress.settledTools.has(tool.id)) { + progress.settledTools.add(tool.id); + const meta = progress.announcedTools.get(tool.id) as AnnouncedCall; + events.push({ + kind: "tool_end", + id: tool.id, + name: tool.name, + label: labelTense(meta.label, "done"), + summary: meta.summary, + ok: status === "success", + result: fullText(tool.results), + args: meta.args, + }); + } + } + } + return events; +} + +/** + * Whether the newest user message's turn has finished. The backend stamps + * `outcome` onto the turn's user message with backend_status "pending" at turn + * START and flips it to a terminal value (success_build, error_build, + * error_backend, stopped, success_no_generation) at end-of-loop — through + * auto-fix, whose activity we keep streaming meanwhile. Authoritative, unlike + * the app's status field, which flaps mid-turn. + */ +export function turnSettled(messages: ConversationMessage[]): boolean { + return newestUserTurn(messages)?.settled ?? false; +} + +interface UserTurn { + id: string; + settled: boolean; + backendStatus?: string; +} + +/** The newest user message and whether its turn reached a terminal outcome. */ +export function newestUserTurn( + messages: ConversationMessage[], +): UserTurn | null { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i]; + if (message.role === "user" && !message.hidden) { + const outcome = message.outcome as { backend_status?: string } | null; + const backendStatus = + outcome && typeof outcome === "object" + ? outcome.backend_status + : undefined; + return { + id: message.id, + settled: outcome != null && backendStatus !== "pending", + backendStatus, + }; + } + } + return null; +} + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +interface StreamOptions { + branchId?: string; + intervalMs?: number; +} + +function makePoller( + onEvent: (event: StreamEvent) => void, + options: StreamOptions, +) { + const state = newStreamState(); + return async (prime = false): Promise => { + try { + const messages = await getFullConversation(30, options.branchId); + const events = diffConversation(state, messages); + if (!prime) for (const event of events) onEvent(event); + return messages; + } catch { + return []; // Transient read failure — the next tick retries. + } + }; +} + +/** + * Run `start` while live-emitting the conversation it drives. The current + * snapshot is consumed FIRST (earlier turns are never replayed), then `start` + * fires, and the conversation is polled until its promise settles — with one + * final read so nothing between the last tick and settlement is lost. + * `start`'s result or rejection passes through untouched. + */ +export async function streamConversationDuring( + start: () => Promise, + onEvent: (event: StreamEvent) => void, + options: StreamOptions = {}, +): Promise { + const intervalMs = options.intervalMs ?? 1_000; + const poll = makePoller(onEvent, options); + await poll(true); + const work = start(); + let pending = true; + const settled = work.then( + () => { + pending = false; + }, + () => { + pending = false; + }, + ); + while (pending) { + await Promise.race([sleep(intervalMs), settled]); + if (!pending) break; + await poll(); + } + await poll(); + return work; +} + +/** + * Live-emit a turn that is already running server-side (the create kickoff), + * until its user message carries an outcome — or the deadline passes. + */ +export async function streamConversationUntilSettled( + onEvent: (event: StreamEvent) => void, + options: StreamOptions & { timeoutMs?: number } = {}, +): Promise<"settled" | "timeout"> { + const intervalMs = options.intervalMs ?? 1_000; + const deadline = Date.now() + (options.timeoutMs ?? 20 * 60_000); + const poll = makePoller(onEvent, options); + while (Date.now() < deadline) { + const messages = await poll(); + if (messages.length > 0 && turnSettled(messages)) return "settled"; + await sleep(intervalMs); + } + return "timeout"; +} diff --git a/packages/cli/tests/cli/builder.spec.ts b/packages/cli/tests/cli/builder.spec.ts new file mode 100644 index 000000000..be73b43d6 --- /dev/null +++ b/packages/cli/tests/cli/builder.spec.ts @@ -0,0 +1,545 @@ +import { existsSync } from "node:fs"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { setupCLITests } from "./testkit/index.js"; + +const USER = { email: "test@example.com", name: "Test User" }; + +describe("builder", () => { + const t = setupCLITests(); + + it("new creates a template app, waits for the settled turn, returns the preview", async () => { + await t.givenLoggedIn(USER); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/apps", (req, res) => { + sentBody = req.body as Record; + return res.json({ id: "app-1", name: "invoice-tracker" }); + }); + // A template app works on main: no branches to scope to. + t.api.mockRoute("GET", "/api/apps/app-1/branches", (_req, res) => + res.json([]), + ); + // The turn's user message already carries a terminal outcome → settles on + // the first poll. + t.api.mockRoute( + "GET", + "/api/apps/app-1/chat/full-conversation", + (_req, res) => + res.json({ + messages: [ + { + id: "u1", + role: "user", + content: "invoice tracker", + outcome: { backend_status: "success_build" }, + }, + ], + }), + ); + t.api.mockRoute("GET", "/api/apps/app-1", (_req, res) => + res.json({ id: "app-1", status: { state: "ready" } }), + ); + t.api.mockRoute("GET", "/api/apps/app-1/sandbox/preview-url", (_req, res) => + res.json({ preview_url: "preview-app-1.base44.app" }), + ); + + const result = await t.run("builder", "new", "invoice tracker", "--json"); + t.expectResult(result).toSucceed(); + expect(sentBody).toMatchObject({ + initial_message: { content: "invoice tracker" }, + }); + // No app_type and no repo fields: the backend defaults to a user_app. + expect(sentBody).not.toHaveProperty("app_type"); + expect(sentBody).not.toHaveProperty("imported_source_mode"); + expect(JSON.parse(result.stdout)).toMatchObject({ + id: "app-1", + status: "ready", + preview_url: "https://preview-app-1.base44.app", + dir: expect.stringMatching(/^[a-z0-9-]+$/), + path: expect.stringMatching(/[\\/][a-z0-9-]+$/), // absolute, either separator + repo_url: null, + }); + }); + + it("new --import builds over an existing repository", async () => { + await t.givenLoggedIn(USER); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/apps", (req, res) => { + sentBody = req.body as Record; + return res.json({ + id: "imp-1", + name: "my-store", + imported_repo_url: "https://github.com/me/my-store", + }); + }); + const result = await t.run( + "builder", + "new", + "--import", + "https://github.com/me/my-store", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(sentBody).toMatchObject({ + app_type: "imported_app", + imported_source_mode: "direct", + imported_repo_url: "https://github.com/me/my-store", + name: "my-store", + }); + expect(JSON.parse(result.stdout)).toMatchObject({ + id: "imp-1", + repo_url: "https://github.com/me/my-store", + status: "created", + dir: expect.stringMatching(/^[a-z0-9-]+$/), + }); + }); + + it("new in an empty directory links that directory and names the app after it", async () => { + await t.givenLoggedIn(USER); + // An empty cwd is the project itself — the `base44 create` rule. + await t.givenProject(await mkdtemp(join(tmpdir(), "b44-empty-"))); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/apps", (req, res) => { + sentBody = req.body as Record; + return res.json({ id: "app-here" }); + }); + const result = await t.run( + "builder", + "new", + "--import", + "https://github.com/me/my-store", + "--json", + ); + t.expectResult(result).toSucceed(); + // The folder names the app, ahead of the repo's own name. + expect(sentBody).toMatchObject({ name: "project" }); + const out = JSON.parse(result.stdout); + expect(out.dir).toBe("."); + expect(out.path.replace(/\\/g, "/")).toMatch(/\/project$/); + expect(existsSync(join(out.path, "base44"))).toBe(true); + }); + + it("new --path links the given directory instead of ./", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("POST", "/api/apps", (_req, res) => + res.json({ id: "app-path" }), + ); + const result = await t.run( + "builder", + "new", + "--import", + "https://github.com/me/my-store", + "--path", + "apps/shop", + "--json", + ); + t.expectResult(result).toSucceed(); + const out = JSON.parse(result.stdout); + expect(out.dir.replace(/\\/g, "/")).toBe("apps/shop"); + expect(out.path.replace(/\\/g, "/")).toMatch(/\/apps\/shop$/); + expect(existsSync(join(out.path, "base44"))).toBe(true); + }); + + it("new --wix-instance creates through the Wix route with the prompt argument", async () => { + await t.givenLoggedIn(USER); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/wix/create-app", (req, res) => { + sentBody = req.body as Record; + return res.json({ app_id: "wix-1", client_creation_id: "initial-abc" }); + }); + t.api.mockRoute("GET", "/api/apps/wix-1/branches", (_req, res) => + res.json([]), + ); + t.api.mockRoute( + "GET", + "/api/apps/wix-1/chat/full-conversation", + (_req, res) => + res.json({ + messages: [ + { + id: "u1", + role: "user", + content: "x", + outcome: { backend_status: "success_build" }, + }, + ], + }), + ); + t.api.mockRoute("GET", "/api/apps/wix-1", (_req, res) => + res.json({ id: "wix-1", status: { state: "ready" } }), + ); + t.api.mockRoute("GET", "/api/apps/wix-1/sandbox/preview-url", (_req, res) => + res.json({ preview_url: "preview-wix-1.base44.app" }), + ); + const result = await t.run( + "builder", + "new", + "add online booking", + "--wix-instance", + "SIGNED.INSTANCE", + "--wix-client-id", + "client-9", + "--name", + "spa", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(sentBody).toEqual({ + prompt: "add online booking", + signed_instance: "SIGNED.INSTANCE", + wix_client_id: "client-9", + }); + expect(JSON.parse(result.stdout)).toMatchObject({ + id: "wix-1", + client_creation_id: "initial-abc", + status: "ready", + preview_url: "https://preview-wix-1.base44.app", + }); + }); + + it("new --wix-instance needs a prompt, and --wix-client-id needs --wix-instance", async () => { + await t.givenLoggedIn(USER); + const noPrompt = await t.run( + "builder", + "new", + "--wix-instance", + "S1", + "--json", + ); + t.expectResult(noPrompt).toFail(); + expect(JSON.parse(noPrompt.stdout).error).toContain("prompt"); + const orphanId = await t.run( + "builder", + "new", + "x", + "--wix-client-id", + "c1", + "--json", + ); + t.expectResult(orphanId).toFail(); + expect(JSON.parse(orphanId.stdout).error).toContain("--wix-instance"); + }); + + it("new --wix-instance - reads the token from stdin", async () => { + await t.givenLoggedIn(USER); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/wix/create-app", (req, res) => { + sentBody = req.body as Record; + return res.json({ app_id: "wix-3", client_creation_id: "initial-ghi" }); + }); + t.api.mockRoute("GET", "/api/apps/wix-3/branches", (_req, res) => + res.json([]), + ); + t.api.mockRoute( + "GET", + "/api/apps/wix-3/chat/full-conversation", + (_req, res) => + res.json({ + messages: [ + { + id: "u1", + role: "user", + content: "x", + outcome: { backend_status: "success_build" }, + }, + ], + }), + ); + t.api.mockRoute("GET", "/api/apps/wix-3", (_req, res) => + res.json({ id: "wix-3", status: { state: "ready" } }), + ); + t.api.mockRoute("GET", "/api/apps/wix-3/sandbox/preview-url", (_req, res) => + res.json({ preview_url: "preview-wix-3.base44.app" }), + ); + t.givenStdin("FROM.STDIN\n"); + const result = await t.run( + "builder", + "new", + "x", + "--wix-instance", + "-", + "--name", + "spa3", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(sentBody).toEqual({ prompt: "x", signed_instance: "FROM.STDIN" }); + }); + + it("new validates its inputs before calling the API", async () => { + await t.givenLoggedIn(USER); + t.expectResult(await t.run("builder", "new", "--json")).toFail(); + t.expectResult( + await t.run("builder", "new", "x", "--mode", "fork", "--json"), + ).toFail(); + t.expectResult( + await t.run("builder", "new", "x", "--name", "no/slashes", "--json"), + ).toFail(); + }); + + it("send scopes to the sole active branch and surfaces the reply", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id", (_req, res) => + res.json({ id: "test-app-id", status: { state: "ready" } }), + ); + t.api.mockRoute("GET", "/api/apps/test-app-id/branches", (_req, res) => + res.json([ + { id: "b1", branch_name: "base44/setup-abc", status: "active" }, + ]), + ); + let sentBranchId: unknown; + t.api.mockRoute( + "POST", + "/api/apps/test-app-id/chat/message", + (req, res) => { + sentBranchId = req.query.branch_id; + return res.json({ + id: "test-app-id", + status: { state: "ready" }, + conversation: { + id: "conv-1", + messages: [ + { role: "user", content: "add login" }, + { role: "assistant", content: "Added session-based login." }, + ], + }, + }); + }, + ); + const result = await t.run( + "builder", + "send", + "add login", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(sentBranchId).toBe("b1"); + expect(JSON.parse(result.stdout)).toEqual({ + status: "ready", + error_source: null, + reply: "Added session-based login.", + }); + }); + + it("send reports a queued turn instead of inventing a reply", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id", (_req, res) => + res.json({ id: "test-app-id", status: { state: "ready" } }), + ); + t.api.mockRoute("GET", "/api/apps/test-app-id/branches", (_req, res) => + res.json([]), + ); + t.api.mockRoute("POST", "/api/apps/test-app-id/chat/message", (_req, res) => + res.json({ queued: true }), + ); + const result = await t.run( + "builder", + "send", + "one more thing", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ queued: true }); + }); + + it("send --stream-json emits one JSON line per event, then a result line", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id", (_req, res) => + res.json({ id: "test-app-id", status: { state: "ready" } }), + ); + t.api.mockRoute("GET", "/api/apps/test-app-id/branches", (_req, res) => + res.json([]), + ); + t.api.mockRoute("POST", "/api/apps/test-app-id/chat/message", (_req, res) => + res.json({ + id: "test-app-id", + status: { state: "ready" }, + conversation: { + id: "conv-1", + messages: [ + { role: "user", content: "add login" }, + { role: "assistant", content: "Done: session-based login." }, + ], + }, + }), + ); + const result = await t.run( + "builder", + "send", + "add login", + "--app-id", + "test-app-id", + "--stream-json", + ); + t.expectResult(result).toSucceed(); + const lines = result.stdout + .trim() + .split("\n") + .map((l: string) => JSON.parse(l)); + expect(lines.at(-1)).toEqual({ + type: "result", + queued: false, + status: "ready", + error_source: null, + reply: "Done: session-based login.", + }); + for (const line of lines) expect(typeof line.type).toBe("string"); + }); + + it("send refuses a code-first project (base44 create)", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id", (_req, res) => + res.json({ id: "test-app-id", is_managed_source_code: false }), + ); + const result = await t.run( + "builder", + "send", + "hi", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain("code-first"); + }); + + it("send refuses a Superagent", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id", (_req, res) => + res.json({ id: "test-app-id", app_type: "user_agent" }), + ); + const result = await t.run( + "builder", + "send", + "hi", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain("Superagent"); + }); + + it("status reports the app's build state", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id", (_req, res) => + res.json({ + id: "test-app-id", + status: { state: "processing", message: "building" }, + }), + ); + const result = await t.run( + "builder", + "status", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + id: "test-app-id", + state: "processing", + message: "building", + }); + }); + + it("preview prints a clickable preview URL", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute( + "GET", + "/api/apps/test-app-id/sandbox/preview-url", + (_req, res) => res.json({ preview_url: "3000-x.e2b.app" }), + ); + const result = await t.run( + "sandbox", + "preview", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(JSON.parse(result.stdout)).toEqual({ + preview_url: "https://3000-x.e2b.app", + }); + }); + + it("stop halts the turn on the active branch", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/apps/test-app-id/branches", (_req, res) => + res.json([ + { id: "b1", branch_name: "base44/setup-abc", status: "active" }, + ]), + ); + let sentBranchId: unknown; + t.api.mockRoute("POST", "/api/apps/test-app-id/chat/stop", (req, res) => { + sentBranchId = req.query.branch_id; + return res.json({}); + }); + const result = await t.run( + "builder", + "stop", + "--app-id", + "test-app-id", + "--json", + ); + t.expectResult(result).toSucceed(); + expect(sentBranchId).toBe("b1"); + expect(JSON.parse(result.stdout)).toEqual({ stopped: true }); + }); + + it("code refuses to run without a terminal", async () => { + await t.givenLoggedIn(USER); + const result = await t.run("code", "--json"); + t.expectResult(result).toFail(); + expect(JSON.parse(result.stdout).error).toContain("terminal"); + }); + it("model lists the catalog with the current pick", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/auth/me", (_req, res) => + res.json({ id: "u1", ...USER, builder_model: "claude_opus_5" }), + ); + const result = await t.run("builder", "model", "--json"); + t.expectResult(result).toSucceed(); + const out = JSON.parse(result.stdout); + expect(out.current).toBe("claude_opus_5"); + expect(out.models.map((m: { name: string }) => m.name)).toContain("Opus 5"); + }); + + it("model persists the pick to the account", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/auth/me", (_req, res) => + res.json({ id: "u1", ...USER, builder_model: null }), + ); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/auth/u1/update-user", (req, res) => { + sentBody = req.body as Record; + return res.json({}); + }); + const result = await t.run("builder", "model", "sonnet", "--json"); + t.expectResult(result).toSucceed(); + expect(sentBody).toEqual({ builder_model: "claude-sonnet-5" }); + expect(JSON.parse(result.stdout)).toEqual({ current: "claude-sonnet-5" }); + }); + + it("model default clears the pick so Base44 chooses (Automatic)", async () => { + await t.givenLoggedIn(USER); + t.api.mockRoute("GET", "/api/auth/me", (_req, res) => + res.json({ id: "u1", ...USER, builder_model: "claude_opus_5" }), + ); + let sentBody: Record | undefined; + t.api.mockRoute("POST", "/api/auth/u1/update-user", (req, res) => { + sentBody = req.body as Record; + return res.json({}); + }); + const result = await t.run("builder", "model", "default", "--json"); + t.expectResult(result).toSucceed(); + expect(sentBody).toEqual({ builder_model: null }); + expect(JSON.parse(result.stdout)).toEqual({ current: null }); + }); +}); diff --git a/packages/cli/tests/cli/logo.spec.ts b/packages/cli/tests/cli/logo.spec.ts new file mode 100644 index 000000000..b7fff5260 --- /dev/null +++ b/packages/cli/tests/cli/logo.spec.ts @@ -0,0 +1,41 @@ +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; +import { LOGO_COLS, logoRows } from "@/cli/commands/code/logo.js"; + +// The exact rows `circle.py -d 6 --gap 3.5r --gap-height=0.8r --no-color` prints +// on an octant-capable terminal (--style octant) … +const OCTANT = [ + " \u{2582}\u{2584}\u{2586}\u{2588}\u{2588}\u{2588}\u{2588}\u{2586}\u{2584}\u{2582} ", + " \u{259F}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599} ", + "\u{1CDD5}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{1CDC0}", + "\u{1CD05}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{1CD02}", + " \u{1CD99}\u{2586}\u{2586}\u{2586}\u{2586}\u{2586}\u{2586}\u{2586}\u{2586}\u{2586}\u{2586}\u{1CD4E} ", + " \u{1FB82}\u{2580}\u{1FB85}\u{2588}\u{2588}\u{2588}\u{2588}\u{1FB85}\u{2580}\u{1FB82} ", +]; + +// … and everywhere else (--style quad). +const QUAD = [ + " \u{2597}\u{2584}\u{259F}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599}\u{2584}\u{2596} ", + " \u{259F}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2599} ", + "\u{2590}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{2588}\u{258C}", + "\u{259D}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2580}\u{2598}", + " \u{2597}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2596} ", + " \u{259D}\u{2580}\u{259C}\u{2588}\u{2588}\u{2588}\u{2588}\u{259B}\u{2580}\u{2598} ", +]; + +describe("logo", () => { + it("renders the circle.py mark glyph for glyph (octant tier)", () => { + expect(logoRows(undefined, "octant")).toEqual(OCTANT); + }); + + it("renders the circle.py mark glyph for glyph (quad tier)", () => { + expect(logoRows(undefined, "quad")).toEqual(QUAD); + }); + + it("keeps every coloured row LOGO_COLS cells wide", () => { + for (const row of logoRows("#E86B3C")) { + const visible = Array.from(stripAnsi(row)); + expect(visible).toHaveLength(LOGO_COLS); + } + }); +}); diff --git a/packages/cli/tests/cli/pending-card.spec.ts b/packages/cli/tests/cli/pending-card.spec.ts new file mode 100644 index 000000000..7eb239f7a --- /dev/null +++ b/packages/cli/tests/cli/pending-card.spec.ts @@ -0,0 +1,161 @@ +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; +import { + cardKey, + cardLines, + cardText, + openCard, +} from "@/cli/commands/code/pending-card.js"; +import type { PendingInput } from "@/core/resources/apps/pending.js"; + +const base = { toolCallId: "tc1", messageId: "m1" }; + +describe("pending card", () => { + it("approval: y approves, n rejects, Esc defers", () => { + const p: PendingInput = { + ...base, + tool: "enable_connector", + kind: "approval", + title: "Enable wix_stores?", + }; + expect(cardKey(openCard(p), "y").submit).toEqual({ + action: "approved", + input: {}, + }); + expect(cardKey(openCard(p), "n").submit).toEqual({ + action: "rejected", + input: {}, + }); + expect(cardKey(openCard(p), "escape")).toEqual({ + state: null, + dismissed: true, + }); + expect(stripAnsi(cardLines(openCard(p)).join("\n"))).toContain( + "y approve · n reject · Esc later", + ); + }); + + it("choice: single-select picks on Enter and moves on; the last answer submits the web payload", () => { + const p: PendingInput = { + ...base, + tool: "ask_clarifying_questions", + kind: "choice", + title: "Two quick questions", + questions: [ + { + question: "Layout?", + options: [{ label: "Grid" }, { label: "List" }], + multiSelect: false, + }, + { + question: "Sections?", + options: [{ label: "Hero" }, { label: "FAQ" }], + multiSelect: true, + }, + ], + }; + let s = openCard(p); + s = cardKey(s, "down").state as typeof s; // → List + let out = cardKey(s, "enter"); + expect(out.submit).toBeUndefined(); + s = out.state as typeof s; + expect(s.step).toBe(1); + s = cardKey(s, "space").state as typeof s; // tick Hero + s = cardKey(s, "down").state as typeof s; + s = cardKey(s, "space").state as typeof s; // tick FAQ + out = cardKey(s, "enter"); + expect(out.submit).toEqual({ + action: "approved", + input: { + answers: [ + { question_index: 0, selected_label: "List" }, + { question_index: 1, selected_labels: ["Hero", "FAQ"] }, + ], + }, + }); + }); + + it("choice: 'something else' captures free text; s skips everything", () => { + const p: PendingInput = { + ...base, + tool: "ask_clarifying_questions", + kind: "choice", + title: "One question", + questions: [ + { + question: "Colour?", + options: [{ label: "Blue" }], + multiSelect: false, + }, + ], + }; + let s = openCard(p); + s = cardKey(s, "down").state as typeof s; // → something else + s = cardKey(s, "enter").state as typeof s; + expect(s.typing).toBe("custom"); + const out = cardText(s, " warm terracotta "); + expect(out.submit).toEqual({ + action: "approved", + input: { + answers: [{ question_index: 0, custom_text: "warm terracotta" }], + }, + }); + expect(cardKey(openCard(p), "s").submit).toEqual({ + action: "approved", + input: { answers: [] }, + }); + }); + + it("permissions: rows start ticked, space toggles, Enter grants the ticked keys", () => { + const p: PendingInput = { + ...base, + tool: "request_agent_tool_permissions", + kind: "permissions", + title: "Grant?", + permissions: [ + { key: "entity:Task", label: "entity: Task (read)" }, + { + key: "backend_function:sendDigest", + label: "backend_function: sendDigest", + }, + ], + }; + let s = openCard(p); + s = cardKey(s, "down").state as typeof s; + s = cardKey(s, "space").state as typeof s; // untick sendDigest + expect(cardKey(s, "enter").submit).toEqual({ + action: "approved", + input: { approved_permission_keys: ["entity:Task"] }, + }); + expect(cardKey(s, "n").submit).toEqual({ action: "rejected", input: {} }); + }); + + it("secrets: values are captured field by field, submitted once, and never rendered", () => { + const p: PendingInput = { + ...base, + tool: "set_secrets", + kind: "secrets", + title: "Two secrets", + secrets: [ + { name: "STRIPE_KEY", description: "dashboard" }, + { name: "MAIL_TOKEN" }, + ], + }; + let s = openCard(p); + expect(s.typing).toBe("secret"); + expect(cardText(s, " ").state).toBe(s); // empty: stay on the field + s = cardText(s, "sk_live_abc").state as typeof s; + expect(s.step).toBe(1); + const rendered = stripAnsi(cardLines(s).join("\n")); + expect(rendered).toContain("✓ STRIPE_KEY"); + expect(rendered).not.toContain("sk_live_abc"); + const out = cardText(s, "tok_xyz"); + expect(out.submit).toEqual({ + action: "approved", + input: { secrets: { STRIPE_KEY: "sk_live_abc", MAIL_TOKEN: "tok_xyz" } }, + }); + expect(out.state).toBeNull(); + // Esc while typing a secret drops the card and everything typed so far. + expect(cardKey(s, "escape")).toEqual({ state: null, dismissed: true }); + }); +}); diff --git a/packages/cli/tests/core/pending.spec.ts b/packages/cli/tests/core/pending.spec.ts new file mode 100644 index 000000000..8e1ea9382 --- /dev/null +++ b/packages/cli/tests/core/pending.spec.ts @@ -0,0 +1,193 @@ +import { describe, expect, it } from "vitest"; +import type { ConversationMessage } from "@/core/resources/apps/api.js"; +import { + choiceAnswers, + pendingInputs, + permissionKey, +} from "@/core/resources/apps/pending.js"; + +const parked = ( + name: string, + args: Record, + results: unknown = "waiting for user input / approval", +): ConversationMessage => ({ + id: "m1", + role: "assistant", + tool_calls: [ + { + id: `tc-${name}`, + name, + arguments_string: JSON.stringify(args), + status: "waiting_for_user_input", + results, + }, + ], +}); + +describe("pendingInputs", () => { + it("ignores calls that are not waiting", () => { + const done = parked("enable_connector", { + integration_type: "wix_stores", + summary: "x", + }); + (done.tool_calls as { status: string }[])[0].status = "success"; + expect(pendingInputs([done])).toEqual([]); + }); + + it("reads a connector approval", () => { + const [p] = pendingInputs([ + parked("enable_connector", { + integration_type: "wix_stores", + summary: "Sell the pillows through Wix Stores", + }), + ]); + expect(p).toMatchObject({ + kind: "approval", + toolCallId: "tc-enable_connector", + messageId: "m1", + title: "Enable wix_stores?", + detail: "Sell the pillows through Wix Stores", + }); + }); + + it("reads a guard-parked call from its results", () => { + const [p] = pendingInputs([ + parked( + "run_shell_command", + { command: "rm -rf dist" }, + { + guard: "bash", + reason: "Deletes files outside the build output", + }, + ), + ]); + expect(p).toMatchObject({ + kind: "approval", + title: "bash: needs your approval", + detail: "Deletes files outside the build output", + }); + }); + + it("reads clarifying questions with options and multi-select", () => { + const [p] = pendingInputs([ + parked("ask_clarifying_questions", { + questions: [ + { + question: "Which layout?", + options: [ + { label: "Grid" }, + { label: "List", description: "one per row" }, + ], + }, + { + question: "Which sections?", + multi_select: true, + options: [{ label: "Hero" }, { label: "FAQ" }], + }, + ], + }), + ]); + expect(p.kind).toBe("choice"); + expect(p.questions).toEqual([ + { + question: "Which layout?", + description: undefined, + multiSelect: false, + options: [ + { label: "Grid", description: undefined }, + { label: "List", description: "one per row" }, + ], + }, + { + question: "Which sections?", + description: undefined, + multiSelect: true, + options: [ + { label: "Hero", description: undefined }, + { label: "FAQ", description: undefined }, + ], + }, + ]); + }); + + it("reads a secrets form without ever seeing values", () => { + const [p] = pendingInputs([ + parked("set_secrets", { + secrets_schema: [ + { + secretName: "STRIPE_KEY", + description: "From the Stripe dashboard", + }, + ], + }), + ]); + expect(p).toMatchObject({ + kind: "secrets", + secrets: [ + { name: "STRIPE_KEY", description: "From the Stripe dashboard" }, + ], + }); + }); + + it("reads permission rows with the backend's keys", () => { + const [p] = pendingInputs([ + parked("request_agent_tool_permissions", { + reason: "So the assistant can manage tasks", + requested_permissions: [ + { + type: "entity", + entity_name: "Task", + allowed_operations: ["read", "update"], + }, + { type: "backend_function", function_name: "sendDigest" }, + { + type: "app_user_connector", + connector_id: "c1", + connector_name: "Gmail", + }, + ], + }), + ]); + expect(p.kind).toBe("permissions"); + expect(p.permissions?.map((r) => r.key)).toEqual([ + "entity:Task", + "backend_function:sendDigest", + "app_user_connector:c1", + ]); + expect(p.permissions?.[0].label).toBe("entity: Task (read, update)"); + expect(permissionKey({ type: "entity" })).toBeNull(); + }); + + it("marks OAuth-style tools as browser steps", () => { + const [p] = pendingInputs([parked("connect_github_account", {})]); + expect(p.kind).toBe("browser"); + }); +}); + +describe("choiceAnswers", () => { + it("builds the web client's payload, skipping unanswered questions", () => { + const questions = [ + { question: "Layout?", options: [{ label: "Grid" }], multiSelect: false }, + { + question: "Sections?", + options: [{ label: "Hero" }, { label: "FAQ" }], + multiSelect: true, + }, + { question: "Colour?", options: [{ label: "Blue" }], multiSelect: false }, + ]; + expect( + choiceAnswers(questions, [ + { labels: ["Grid"] }, + { labels: ["Hero", "FAQ"] }, + { labels: [], customText: "Something warmer" }, + ]), + ).toEqual({ + answers: [ + { question_index: 0, selected_label: "Grid" }, + { question_index: 1, selected_labels: ["Hero", "FAQ"] }, + { question_index: 2, custom_text: "Something warmer" }, + ], + }); + expect(choiceAnswers(questions, [])).toEqual({ answers: [] }); + }); +}); diff --git a/packages/cli/tests/core/stream.spec.ts b/packages/cli/tests/core/stream.spec.ts new file mode 100644 index 000000000..00c3d0e13 --- /dev/null +++ b/packages/cli/tests/core/stream.spec.ts @@ -0,0 +1,491 @@ +import chalk from "chalk"; +import stripAnsi from "strip-ansi"; +import { describe, expect, it } from "vitest"; +import { + createTurnStream, + eventLine, + formatDuration, + renderEntry, + runningLine, + shimmer, +} from "@/cli/commands/code/render.js"; +import type { ConversationMessage } from "@/core/resources/apps/api.js"; +import { + diffConversation, + newStreamState, + toolMeta, + turnSettled, +} from "@/core/resources/apps/stream.js"; + +const assistant = ( + overrides: Partial & { id: string }, +): ConversationMessage => ({ + role: "assistant", + content: null, + ...overrides, +}); + +describe("diffConversation", () => { + it("emits each item once across polls: announce, then settle, then nothing", () => { + const state = newStreamState(); + const running = assistant({ + id: "m1", + reasoning: { content: "Choosing FastAPI." }, + tool_calls: [ + { + id: "t1", + name: "run_shell_command", + arguments_string: + '{"command": "docker compose up -d", "summary": "Boot the stack"}', + status: "running", + results: null, + }, + ], + }); + + expect(diffConversation(state, [running])).toEqual([ + { kind: "thinking", text: "Choosing FastAPI." }, + { + kind: "tool_start", + id: "t1", + name: "run_shell_command", + label: "Boot the stack", + summary: "docker compose up -d", + }, + ]); + + const settled = assistant({ + ...running, + content: "The stack is up.", + tool_calls: [ + { + ...running.tool_calls?.[0], + status: "success", + results: "3 containers started", + }, + ], + } as ConversationMessage); + expect(diffConversation(state, [settled])).toEqual([ + { kind: "text", text: "The stack is up." }, + { + kind: "tool_end", + id: "t1", + name: "run_shell_command", + label: "Boot the stack", + summary: "docker compose up -d", + ok: true, + result: "3 containers started", + args: { command: "docker compose up -d", summary: "Boot the stack" }, + }, + ]); + + expect(diffConversation(state, [settled])).toEqual([]); + }); + + it("splits two-tense labels: present while running, past when done", () => { + const state = newStreamState(); + const running = assistant({ + id: "m1", + tool_calls: [ + { + id: "t1", + name: "run_shell_command", + arguments_string: + '{"command":"astro dev --help","summary":"Checking astro dev CLI flags | Checked astro dev CLI flags"}', + status: "running", + results: null, + }, + ], + }); + const [start] = diffConversation(state, [running]); + expect(start).toMatchObject({ + kind: "tool_start", + label: "Checking astro dev CLI flags", + }); + const done = assistant({ + ...running, + tool_calls: [ + { ...running.tool_calls?.[0], status: "success", results: "ok" }, + ], + } as ConversationMessage); + const [end] = diffConversation(state, [done]); + expect(end).toMatchObject({ + kind: "tool_end", + label: "Checked astro dev CLI flags", + }); + }); + + it("emits only the newly appended part of growing text", () => { + const state = newStreamState(); + diffConversation(state, [ + assistant({ id: "m1", content: "Scaffolding the backend." }), + ]); + expect( + diffConversation(state, [ + assistant({ + id: "m1", + content: "Scaffolding the backend. Now the frontend.", + }), + ]), + ).toEqual([{ kind: "text", text: "Now the frontend." }]); + }); + + it("marks a failed tool and flattens structured results", () => { + const state = newStreamState(); + expect( + diffConversation(state, [ + assistant({ + id: "m1", + tool_calls: [ + { + id: "t1", + name: "edit_repo_file", + arguments_string: '{"path": "backend/app/db.py"}', + status: "error", + results: { error: "File not found" }, + }, + ], + }), + ]), + ).toEqual([ + { + kind: "tool_start", + id: "t1", + name: "edit_repo_file", + label: "", + summary: "backend/app/db.py", + }, + { + kind: "tool_end", + id: "t1", + name: "edit_repo_file", + label: "", + summary: "backend/app/db.py", + ok: false, + result: '{"error":"File not found"}', + args: { path: "backend/app/db.py" }, + }, + ]); + }); + + it("ignores user and hidden messages", () => { + const state = newStreamState(); + expect( + diffConversation(state, [ + { id: "u1", role: "user", content: "add login" }, + assistant({ id: "h1", hidden: true, content: "internal" }), + ]), + ).toEqual([]); + }); + + it("a primed state suppresses history but streams what comes after", () => { + const state = newStreamState(); + const history = assistant({ id: "m0", content: "Earlier turn summary." }); + diffConversation(state, [history]); // prime + expect( + diffConversation(state, [ + history, + assistant({ id: "m1", content: "New turn begins." }), + ]), + ).toEqual([{ kind: "text", text: "New turn begins." }]); + }); +}); + +describe("toolMeta", () => { + it("separates the human title (summary arg) from the salient argument", () => { + expect( + toolMeta( + "run_shell_command", + '{"command":"ls -la","summary":"List the tree"}', + ), + ).toEqual({ label: "List the tree", summary: "ls -la" }); + expect( + toolMeta("write_repo_file", '{"path":"a.py","content":"…"}'), + ).toEqual({ label: "", summary: "a.py" }); + expect( + toolMeta("create_pull_request", '{"title":"Add auth","body":"x"}'), + ).toEqual({ label: "", summary: "Add auth" }); + }); + + it("falls back to the first non-summary string and survives non-JSON", () => { + expect(toolMeta("set_secrets", '{"summary":"3 secrets declared"}')).toEqual( + { label: "3 secrets declared", summary: "" }, + ); + expect(toolMeta("unknown_tool", '{"n":1,"target":"web"}')).toEqual({ + label: "", + summary: "web", + }); + expect(toolMeta("unknown_tool", "not json").summary).toBe("not json"); + }); + + it("salvages keys from truncated arguments JSON", () => { + // Big payloads arrive cut mid-string on the wire — JSON.parse fails, but + // keys that survived must render instead of the raw blob. + expect( + toolMeta( + "write_repo_file", + '{"file_path": "the-sewer-vault/src/pages/shop.astro", "content": "… trunc', + ), + ).toEqual({ label: "", summary: "the-sewer-vault/src/pages/shop.astro" }); + expect( + toolMeta( + "run_shell_command", + '{"summary": "Boot the stack", "command": "docker compose up -d", "timeout": 60', + ), + ).toEqual({ label: "Boot the stack", summary: "docker compose up -d" }); + }); + + it("truncates long values to one line", () => { + const long = `{"command":"${"x".repeat(200)}"}`; + expect(toolMeta("run_shell_command", long).summary).toHaveLength(91); // 90 + ellipsis + }); +}); + +describe("render", () => { + it("title-first line: label leads, params on their own dim line, duration shown", () => { + expect( + stripAnsi( + eventLine( + { + kind: "tool_end", + id: "t1", + name: "run_shell_command", + label: "Confirmed Wix login", + summary: "cd /tmp && node bootstrap.mjs", + ok: true, + result: '{"event":"logged_in"}', + }, + 4000, + ) ?? "", + ), + ).toBe( + '✓ Confirmed Wix login · 4s\n bash: cd /tmp && node bootstrap.mjs\n {"event":"logged_in"}', + ); + }); + + it("aliases tool names and keeps quiet on boring ok results", () => { + expect( + stripAnsi( + eventLine({ + kind: "tool_end", + id: "t1", + name: "write_repo_file", + label: "", + summary: "frontend/src/App.jsx", + ok: true, + result: "Wrote frontend/src/App.jsx", + }) ?? "", + ), + ).toBe("✓ write frontend/src/App.jsx"); + expect( + eventLine({ + kind: "tool_start", + id: "t3", + name: "run_shell_command", + label: "", + summary: "ls", + }), + ).toBeNull(); + }); + + it("renders an edit as the change itself and folds long ones", () => { + const edit = (find: string, replace: string) => + eventLine( + { + kind: "tool_end", + id: "e1", + name: "find_replace", + label: "", + summary: "src/App.jsx", + ok: true, + result: "Success", + args: { file_path: "src/App.jsx", find, replace }, + }, + undefined, + { foldHint: "Ctrl+O to expand" }, + ) ?? ""; + expect(stripAnsi(edit("a\nb", "a\nc"))).toBe( + "✓ edit src/App.jsx\n - a\n - b\n + a\n + c", + ); + const long = stripAnsi( + edit(Array(10).fill("x").join("\n"), Array(10).fill("y").join("\n")), + ); + expect(long).toContain("… +8 lines (Ctrl+O to expand)"); + expect(long.split("\n")).toHaveLength(14); // head + 12 shown + fold marker + }); + + it("verbose shows everything; writes show a line count", () => { + const write = { + kind: "tool_end" as const, + id: "w1", + name: "write_file", + label: "", + summary: "src/pages/Home.jsx", + ok: true, + result: "Edited src/pages/Home.jsx (40 chars): import React…", + args: { + file_path: "src/pages/Home.jsx", + content: "import React;\n\nexport default () => null;", + }, + }; + expect(stripAnsi(eventLine(write) ?? "")).toBe( + "✓ write src/pages/Home.jsx +3 lines", + ); + expect( + stripAnsi(eventLine(write, undefined, { verbose: true }) ?? ""), + ).toBe( + "✓ write src/pages/Home.jsx +3 lines\n import React;\n \n export default () => null;", + ); + }); + + it("keeps errors whole up to a fold, and folds long ok results to one line", () => { + const failed = eventLine({ + kind: "tool_end", + id: "b1", + name: "run_shell_command", + label: "", + summary: "npm test", + ok: false, + result: "FAIL src/a.test.js\n expected 1\n received 2\n\n1 test failed", + }); + expect(stripAnsi(failed ?? "")).toBe( + "✗ bash npm test\n FAIL src/a.test.js\n expected 1\n received 2\n \n 1 test failed", + ); + const chatty = eventLine( + { + kind: "tool_end", + id: "b2", + name: "run_shell_command", + label: "", + summary: "ls", + ok: true, + result: "a.js\nb.js\nc.js", + }, + undefined, + { foldHint: "--verbose shows everything" }, + ); + expect(stripAnsi(chatty ?? "")).toBe( + "✓ bash ls\n a.js … +2 lines (--verbose shows everything)", + ); + }); + + it("draws a running tool in place with a pulsing dot and its detail", () => { + const start = { + kind: "tool_start" as const, + id: "r1", + name: "run_shell_command", + label: "Running the tests", + summary: "npm test", + }; + const t0 = 1_000_000; + // The pulse is a colour change, so give chalk colours (tests run without a TTY). + const level = chalk.level; + chalk.level = 3; + const lit = runningLine(start, t0 - 4_000, t0); + const dim = runningLine(start, t0 - 3_500, t0 + 500); // same elapsed, other pulse phase + chalk.level = level; + expect(stripAnsi(lit)).toBe("● Running the tests · 4s\n └ bash: npm test"); + expect(lit).not.toBe(dim); // the dot pulses + expect(stripAnsi(dim)).toBe(stripAnsi(lit)); + expect(stripAnsi(renderEntry({ running: start, startedAt: t0 }))).toContain( + "Running the tests", + ); + }); + + it("the thinking glyph pulses forward and back over time", () => { + const frames = Array.from({ length: 10 }, (_, i) => shimmer(i * 120)); + expect(frames).toEqual(["·", "✢", "✳", "✶", "✻", "✽", "✻", "✶", "✳", "✢"]); + expect(shimmer(10 * 120)).toBe("·"); + }); + + it("formats durations for humans", () => { + expect(formatDuration(4_000)).toBe("4s"); + expect(formatDuration(272_000)).toBe("4m 32s"); + }); + + it("non-interactive stream prints settled lines only, no ANSI cursor codes", () => { + const out: string[] = []; + const stream = createTurnStream(false, (text) => out.push(text)); + stream.onEvent({ + kind: "tool_start", + id: "t1", + name: "write_repo_file", + label: "", + summary: "a.py", + }); + stream.onEvent({ + kind: "tool_end", + id: "t1", + name: "write_repo_file", + label: "", + summary: "a.py", + ok: true, + result: "Wrote a.py", + }); + stream.stop(); + const joined = stripAnsi(out.join("")); + expect(joined).toBe("✓ write a.py\n"); + expect(out.join("")).not.toContain("\r"); + }); +}); + +describe("hardWrapAnsi", () => { + it("wraps at visible width and keeps style continuity across breaks", async () => { + const { hardWrapAnsi } = await import("@/cli/commands/code/render.js"); + // Raw codes, not chalk — chalk is color-disabled under a non-TTY test run. + const dim = "\u001b[2m"; + const reset = "\u001b[0m"; + const wrapped = hardWrapAnsi(`${dim}${"x".repeat(10)}${reset}`, 4); + expect(wrapped.map((l) => stripAnsi(l))).toEqual(["xxxx", "xxxx", "xx"]); + // Continuation lines reopen the dim code so the style survives the break. + expect(wrapped[1].startsWith(dim)).toBe(true); + expect(wrapped[0].endsWith(reset)).toBe(true); + // Plain text with explicit newlines splits on them. + expect(hardWrapAnsi("ab\ncd", 10)).toEqual(["ab", "cd"]); + }); +}); + +describe("makePasteSanitizer", () => { + it("strips paste markers and flattens newlines to one line", async () => { + const { makePasteSanitizer } = await import("@/cli/commands/code/paste.js"); + const sanitize = makePasteSanitizer(); + expect(sanitize("\x1b[200~line one\nline two\r\nline three\x1b[201~")).toBe( + "line one line two line three", + ); + // Outside a paste, everything passes through untouched (Enter stays Enter). + expect(sanitize("abc\r")).toBe("abc\r"); + }); + + it("handles markers split across chunks", async () => { + const { makePasteSanitizer } = await import("@/cli/commands/code/paste.js"); + const sanitize = makePasteSanitizer(); + const out = + sanitize("\x1b[20") + + sanitize("0~hello\nworld\x1b[2") + + sanitize("01~tail"); + expect(out).toBe("hello worldtail"); + }); +}); + +describe("turnSettled", () => { + const user = (id: string, outcome: unknown): ConversationMessage => ({ + id, + role: "user", + content: "do it", + outcome, + }); + + it("keys off the NEWEST user message's TERMINAL outcome", () => { + const done = user("u1", { backend_status: "success_build" }); + const open = user("u2", null); + // outcome is stamped "pending" at turn START — that must not read as done. + const started = user("u3", { backend_status: "pending" }); + expect(turnSettled([done, assistant({ id: "m1" }), open])).toBe(false); + expect(turnSettled([done, assistant({ id: "m1" }), started])).toBe(false); + expect(turnSettled([open, assistant({ id: "m1" }), done])).toBe(true); + expect(turnSettled([user("u4", { backend_status: "error_build" })])).toBe( + true, + ); + expect(turnSettled([assistant({ id: "m1" })])).toBe(false); + }); +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json index f22df6dd2..3cae31354 100644 --- a/packages/cli/tsconfig.json +++ b/packages/cli/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { + "jsx": "react-jsx", "types": ["node", "bun"], "baseUrl": ".", "paths": { From baeb948d06947792ae0d514959d7c9df493c4f1a Mon Sep 17 00:00:00 2001 From: ayal Date: Sat, 19 Sep 2026 10:03:03 +0300 Subject: [PATCH 2/7] feat(cli): OAuth steps run from the session, plus the remaining card kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A parked OAuth call (connector authorization, GitHub connect) is now handled the way the editor handles it: start the flow, give the user the link (also opened in the browser when there is one), poll the connection's status, and approve the tool call only once it is ACTIVE — approving first made the tool report "no fresh active connection was stored". Failures and timeouts offer a retry. New core module: apps/connections.ts (initiate, wait, GitHub status). Also: select_payment_provider renders as a choice and answers under the tool's own key; a parked tool this CLI cannot render (waiting_on.kind input/choice from an unknown tool) shows an "answer in the editor" card instead of a misleading yes/no; a paywall-ended turn says the workspace is out of credits. Co-Authored-By: Claude Fable 5.1 --- .../cli/src/cli/commands/code/pending-card.ts | 102 ++++++++++++++++-- .../src/cli/commands/code/session-engine.ts | 4 +- .../cli/src/cli/commands/code/session.tsx | 73 +++++++++++++ .../src/core/resources/apps/connections.ts | 94 ++++++++++++++++ .../cli/src/core/resources/apps/pending.ts | 88 ++++++++++++++- packages/cli/tests/cli/pending-card.spec.ts | 69 ++++++++++++ packages/cli/tests/core/pending.spec.ts | 57 ++++++++++ 7 files changed, 478 insertions(+), 9 deletions(-) create mode 100644 packages/cli/src/core/resources/apps/connections.ts diff --git a/packages/cli/src/cli/commands/code/pending-card.ts b/packages/cli/src/cli/commands/code/pending-card.ts index 6d871639a..234d4d97e 100644 --- a/packages/cli/src/cli/commands/code/pending-card.ts +++ b/packages/cli/src/cli/commands/code/pending-card.ts @@ -25,8 +25,17 @@ export interface CardState { typing: "custom" | "secret" | null; /** Secret name → value. Dropped on submit or dismissal. */ secretValues: Record; + /** Browser step: the link once started, and the outcome once known. */ + browser?: { url?: string; status: BrowserStatus }; } +export type BrowserStatus = + | "idle" + | "waiting" + | "active" + | "failed" + | "timeout"; + export type CardKey = | "up" | "down" @@ -43,6 +52,8 @@ interface CardOutcome { submit?: { action: ToolCallAction; input: Record }; /** The user chose "later": hide the card until Tab. */ dismissed?: boolean; + /** Start the browser step (open the link, poll the connection). */ + startBrowser?: boolean; } export function openCard(pending: PendingInput): CardState { @@ -54,6 +65,9 @@ export function openCard(pending: PendingInput): CardState { granted: new Set((pending.permissions ?? []).map((p) => p.key)), typing: pending.kind === "secrets" ? "secret" : null, secretValues: {}, + ...(pending.kind === "browser" + ? { browser: { status: "idle" as const } } + : {}), }; } @@ -75,7 +89,10 @@ function advanceChoice(state: CardState): CardOutcome { state: { ...state, step: state.step + 1, cursor: 0, typing: null }, }; } - return done("approved", choiceAnswers(questions, state.selections)); + return done( + "approved", + choiceAnswers(questions, state.selections, state.pending.answerKey), + ); } function choiceKey(state: CardState, key: CardKey): CardOutcome { @@ -174,8 +191,31 @@ export function cardKey(state: CardState, key: CardKey): CardOutcome { return choiceKey(state, key); case "permissions": return permissionsKey(state, key); + case "browser": { + const status = state.browser?.status ?? "idle"; + if (key === "n") return done("rejected"); + if (key === "escape") return later; + if (key === "y" || key === "enter") { + // Approve only once the connection exists; before that, start it. + if (status === "active") return done("approved"); + if (status !== "waiting") { + return { + state: { + ...state, + browser: { ...state.browser, status: "waiting" }, + }, + startBrowser: true, + }; + } + } + return { state }; + } + case "unknown": + if (key === "n") return done("rejected"); + if (key === "escape") return later; + return { state }; default: - // approval and browser steps: yes / no / later + // approval: yes / no / later if (key === "y" || key === "enter") return done("approved"); if (key === "n") return done("rejected"); if (key === "escape") return later; @@ -183,6 +223,17 @@ export function cardKey(state: CardState, key: CardKey): CardOutcome { } } +/** The browser step progressed: a link to show, or a final outcome. */ +export function browserUpdate( + state: CardState, + update: { url?: string; status: BrowserStatus }, +): CardState { + return { + ...state, + browser: { url: update.url ?? state.browser?.url, status: update.status }, + }; +} + /** A line the user typed into the input box while the card was capturing it. */ export function cardText(state: CardState, text: string): CardOutcome { const value = text.trim(); @@ -266,13 +317,52 @@ export function cardLines(state: CardState): string[] { chalk.dim(" type the value below (hidden) · Enter next · Esc later"), ]; } - case "browser": + case "browser": { + const b = state.browser ?? { status: "idle" as const }; + const link = b.url ? [` ${chalk.cyan(b.url)}`] : []; + switch (b.status) { + case "waiting": + return [ + ...head, + chalk.dim(" opened in your browser — or use the link:"), + ...link, + chalk.dim( + " waiting for the authorization to complete… · n reject · Esc later", + ), + ]; + case "active": + return [ + ...head, + chalk.green(" ✓ connected"), + chalk.dim(" y continue · n reject"), + ]; + case "failed": + return [ + ...head, + chalk.red(" ✗ authorization failed"), + chalk.dim(" y try again · n reject · Esc later"), + ]; + case "timeout": + return [ + ...head, + chalk.yellow(" ⏱ no response yet"), + ...link, + chalk.dim(" y try again · n reject · Esc later"), + ]; + default: + return [ + ...head, + chalk.dim(" y open the authorization link · n reject · Esc later"), + ]; + } + } + case "unknown": return [ ...head, - chalk.dim( - " finish this step in the editor (footer link), then press y", + chalk.yellow( + " this question needs the editor — answer it there and the session continues", ), - chalk.dim(" y continue · n reject · Esc later"), + chalk.dim(" n reject · Esc later"), ]; default: return [...head, chalk.dim(" y approve · n reject · Esc later")]; diff --git a/packages/cli/src/cli/commands/code/session-engine.ts b/packages/cli/src/cli/commands/code/session-engine.ts index 4da2d3b66..620800a83 100644 --- a/packages/cli/src/cli/commands/code/session-engine.ts +++ b/packages/cli/src/cli/commands/code/session-engine.ts @@ -289,7 +289,9 @@ export function createSessionEngine(options: EngineOptions): SessionEngine { ok ? chalk.dim(`— turn finished · ${formatDuration(durationMs)}`) : chalk.red( - `— turn failed (${turn.backendStatus ?? "unknown"}) · ${formatDuration(durationMs)}`, + turn.backendStatus === "error_paywall" + ? `— the workspace is out of credits; nothing ran · ${formatDuration(durationMs)}` + : `— turn failed (${turn.backendStatus ?? "unknown"}) · ${formatDuration(durationMs)}`, ), ); const info: TurnSettleInfo = { diff --git a/packages/cli/src/cli/commands/code/session.tsx b/packages/cli/src/cli/commands/code/session.tsx index 37bcf13b6..954864118 100644 --- a/packages/cli/src/cli/commands/code/session.tsx +++ b/packages/cli/src/cli/commands/code/session.tsx @@ -1,10 +1,12 @@ import chalk from "chalk"; import { Box, render, Text, useApp, useInput } from "ink"; import TextInput from "ink-text-input"; +import open from "open"; import { useEffect, useReducer, useRef, useState } from "react"; import { LOGO_COLS, logoRows } from "@/cli/commands/code/logo.js"; import { createPasteFriendlyStdin } from "@/cli/commands/code/paste.js"; import { + browserUpdate, type CardKey, type CardState, cardKey, @@ -40,6 +42,11 @@ import { isGithubUserTokenError, startGithubReauth, } from "@/core/resources/apps/api.js"; +import { + githubConnected, + startConnectorOAuth, + waitForConnectorOAuth, +} from "@/core/resources/apps/connections.js"; import packageJson from "../../../../package.json"; const FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; @@ -231,6 +238,66 @@ function SessionView({ engine, footer, subscribe }: ViewProps) { } }; + const browserRunRef = useRef(null); + + /** Open the authorization link and wait for the connection, then let the + * card approve. The web does the same with a popup; here it's a link. */ + const runBrowserStep = async (state: CardState) => { + browserRunRef.current?.abort(); + const run = new AbortController(); + browserRunRef.current = run; + const step = state.pending.browser; + try { + let url: string; + let wait: () => Promise<"ACTIVE" | "FAILED" | "PENDING">; + if (step?.flow === "github") { + url = await startGithubReauth(); + wait = async () => { + const deadline = Date.now() + 10 * 60_000; + while (Date.now() < deadline && !run.signal.aborted) { + if (await githubConnected()) return "ACTIVE"; + await new Promise((r) => setTimeout(r, 3_000)); + } + return "PENDING"; + }; + } else { + const started = await startConnectorOAuth({ + integrationType: step?.integrationType ?? "", + scopes: step?.scopes, + connectorId: step?.connectorId, + forceReconnect: step?.forceReconnect, + }); + url = started.url; + wait = () => waitForConnectorOAuth(started, { signal: run.signal }); + } + setCard((c) => (c ? browserUpdate(c, { url, status: "waiting" }) : c)); + emit(chalk.dim(` authorization link: ${url}`)); + await open(url).catch(() => undefined); // headless: the link is printed anyway + const outcome = await wait(); + if (run.signal.aborted) return; + setCard((c) => + c + ? browserUpdate(c, { + status: + outcome === "ACTIVE" + ? "active" + : outcome === "FAILED" + ? "failed" + : "timeout", + }) + : c, + ); + } catch (error) { + if (run.signal.aborted) return; + emit( + chalk.red( + ` authorization failed to start: ${error instanceof Error ? error.message : String(error)}`, + ), + ); + setCard((c) => (c ? browserUpdate(c, { status: "failed" }) : c)); + } + }; + const applyCard = (outcome: { state: CardState | null; submit?: { @@ -238,14 +305,20 @@ function SessionView({ engine, footer, subscribe }: ViewProps) { input: Record; }; dismissed?: boolean; + startBrowser?: boolean; }) => { if (outcome.submit && card) { + browserRunRef.current?.abort(); engine.answer(card.pending, outcome.submit.action, outcome.submit.input); } if (outcome.dismissed && card) { + browserRunRef.current?.abort(); dismissedRef.current.add(card.pending.toolCallId); } setCard(outcome.state); + if (outcome.startBrowser && outcome.state) { + void runBrowserStep(outcome.state); + } }; // Open the oldest unanswered card as soon as the agent parks one; close it diff --git a/packages/cli/src/core/resources/apps/connections.ts b/packages/cli/src/core/resources/apps/connections.ts new file mode 100644 index 000000000..7628d05eb --- /dev/null +++ b/packages/cli/src/core/resources/apps/connections.ts @@ -0,0 +1,94 @@ +import type { KyResponse } from "ky"; +import { z } from "zod"; +import { base44Client, getAppClient } from "@/core/clients/index.js"; +import { ApiError } from "@/core/errors.js"; +import { getOAuthStatus } from "@/core/resources/connector/api.js"; + +/** + * The browser half of a parked OAuth tool call. The web opens a popup and + * approves the call only once the connection exists; the CLI does the same + * with a link the user opens anywhere, polling the same status endpoint. + */ +const InitiateSchema = z.object({ + redirect_url: z.string().nullish(), + connection_id: z.string().nullish(), + integration_type: z.string().nullish(), +}); + +interface StartedOAuth { + url: string; + connectionId: string; + integrationType: string; +} + +export async function startConnectorOAuth(options: { + integrationType: string; + scopes?: string[] | null; + connectorId?: string | null; + forceReconnect?: boolean; +}): Promise { + let response: KyResponse; + try { + response = await getAppClient().post("external-auth/initiate", { + json: { + integration_type: options.integrationType, + scopes: options.scopes ?? null, + connector_id: options.connectorId ?? null, + force_reconnect: options.forceReconnect === true, + }, + }); + } catch (error) { + throw await ApiError.fromHttpError( + error, + "starting connector authorization", + ); + } + const parsed = InitiateSchema.parse(await response.json()); + if (!parsed.redirect_url || !parsed.connection_id) { + throw new ApiError("The connector did not return an authorization link."); + } + return { + url: parsed.redirect_url, + connectionId: parsed.connection_id, + integrationType: parsed.integration_type ?? options.integrationType, + }; +} + +type OAuthOutcome = "ACTIVE" | "FAILED" | "PENDING"; + +/** Poll until the connection is ACTIVE or FAILED, or the deadline passes. */ +export async function waitForConnectorOAuth( + started: StartedOAuth, + options: { + timeoutMs?: number; + intervalMs?: number; + signal?: AbortSignal; + } = {}, +): Promise { + const deadline = Date.now() + (options.timeoutMs ?? 10 * 60_000); + const interval = options.intervalMs ?? 3_000; + while (Date.now() < deadline && !options.signal?.aborted) { + const status = await getOAuthStatus( + started.integrationType as never, + started.connectionId, + ).catch(() => null); + if (status?.status === "ACTIVE" || status?.status === "FAILED") { + return status.status; + } + await new Promise((r) => setTimeout(r, interval)); + } + return "PENDING"; +} + +const GithubStatusSchema = z.object({ connected: z.boolean() }); + +/** Whether the account's GitHub connection is active (what the web checks + * before approving connect_github_account). */ +export async function githubConnected(): Promise { + try { + const response = await base44Client.get("api/github/oauth/status"); + return GithubStatusSchema.parse(await response.json()).connected; + } catch { + return false; + } +} diff --git a/packages/cli/src/core/resources/apps/pending.ts b/packages/cli/src/core/resources/apps/pending.ts index 8d48a0669..1b175c68e 100644 --- a/packages/cli/src/core/resources/apps/pending.ts +++ b/packages/cli/src/core/resources/apps/pending.ts @@ -13,7 +13,18 @@ export type PendingKind = | "choice" | "secrets" | "permissions" - | "browser"; + | "browser" + /** Needs a question or form this CLI cannot render — answer in the editor. */ + | "unknown"; + +export interface BrowserStep { + /** "connector": start OAuth for `integrationType`; "github": the account's GitHub link. */ + flow: "connector" | "github"; + integrationType?: string; + connectorId?: string; + scopes?: string[]; + forceReconnect?: boolean; +} interface PendingOption { label: string; @@ -49,6 +60,10 @@ export interface PendingInput { /** Supporting text: the summary, the guard's reason, the permission request's reason. */ detail?: string; questions?: PendingQuestion[]; + /** For a single list choice: the `extra_user_input` key the tool expects (e.g. "provider"). */ + answerKey?: string; + /** For kind "browser": how to run the step the web runs in a popup. */ + browser?: BrowserStep; secrets?: PendingSecret[]; permissions?: PendingPermission[]; } @@ -58,6 +73,17 @@ const CHOICE_TOOLS = new Set([ "ask_plan_questions", ]); const SECRET_TOOLS = new Set(["set_secrets"]); +/** A single-choice tool whose options live in one arguments array. */ +const LIST_CHOICE_TOOLS: Record< + string, + { key: string; question: string; answer: string } +> = { + select_payment_provider: { + key: "providers", + question: "Which payment provider?", + answer: "provider", + }, +}; const PERMISSION_TOOLS = new Set(["request_agent_tool_permissions"]); /** Approval only after a step the web runs (OAuth popup, payments form). */ const BROWSER_TOOLS = new Set([ @@ -210,10 +236,63 @@ export function pendingInputs(messages: ConversationMessage[]): PendingInput[] { detail: str(args.reason), permissions: permissionsFrom(args), }); + } else if (LIST_CHOICE_TOOLS[call.name]) { + const spec = LIST_CHOICE_TOOLS[call.name]; + const raw = Array.isArray(args[spec.key]) + ? (args[spec.key] as unknown[]) + : []; + const options = raw.flatMap((o) => { + const label = + typeof o === "string" + ? o + : str((o as Record)?.label); + return label ? [{ label }] : []; + }); + out.push({ + ...base, + kind: "choice", + title: summary ?? spec.question, + detail: str(args.reason), + questions: [{ question: spec.question, options, multiSelect: false }], + answerKey: spec.answer, + }); } else if (BROWSER_TOOLS.has(call.name)) { + const integration = str(args.integration_type); out.push({ ...base, kind: "browser", + title: + summary ?? + (call.name === "connect_github_account" + ? "Connect your GitHub account" + : integration + ? `Authorize ${integration}` + : humanize(call.name)), + detail: str(args.reason), + browser: + call.name === "connect_github_account" + ? { flow: "github" } + : { + flow: "connector", + integrationType: integration, + connectorId: str(args.connector_id), + scopes: Array.isArray(args.scopes) + ? (args.scopes as unknown[]).filter( + (x): x is string => typeof x === "string", + ) + : undefined, + forceReconnect: args.force_reconnect === true, + }, + }); + } else if ( + call.waiting_on?.kind === "choice" || + call.waiting_on?.kind === "input" + ) { + // A tool this CLI does not know how to render: say so instead of + // offering a yes/no that would answer the wrong question. + out.push({ + ...base, + kind: "unknown", title: summary ?? humanize(call.name), detail: str(args.reason), }); @@ -247,7 +326,12 @@ export interface ChoiceSelection { export function choiceAnswers( questions: PendingQuestion[], selections: ChoiceSelection[], -): { answers: Record[] } { + answerKey?: string, +): Record { + if (answerKey) { + const first = selections[0]; + return { [answerKey]: first?.labels[0] ?? first?.customText ?? "" }; + } const answers = questions.flatMap((q, index) => { const sel = selections[index]; if (!sel || (sel.labels.length === 0 && !sel.customText)) return []; diff --git a/packages/cli/tests/cli/pending-card.spec.ts b/packages/cli/tests/cli/pending-card.spec.ts index 7eb239f7a..a7b107fbc 100644 --- a/packages/cli/tests/cli/pending-card.spec.ts +++ b/packages/cli/tests/cli/pending-card.spec.ts @@ -1,6 +1,7 @@ import stripAnsi from "strip-ansi"; import { describe, expect, it } from "vitest"; import { + browserUpdate, cardKey, cardLines, cardText, @@ -158,4 +159,72 @@ describe("pending card", () => { // Esc while typing a secret drops the card and everything typed so far. expect(cardKey(s, "escape")).toEqual({ state: null, dismissed: true }); }); + + it("browser step: y starts the flow, approval only once connected, n rejects", () => { + const p: PendingInput = { + ...base, + tool: "request_oauth_authorization", + kind: "browser", + title: "Authorize wix", + browser: { flow: "connector", integrationType: "wix" }, + }; + let s = openCard(p); + expect(stripAnsi(cardLines(s).join("\n"))).toContain( + "y open the authorization link", + ); + const out = cardKey(s, "y"); + expect(out.startBrowser).toBe(true); + expect(out.submit).toBeUndefined(); + s = out.state as typeof s; + expect(cardKey(s, "y").startBrowser).toBeUndefined(); // already waiting: y is inert + s = browserUpdate(s, { url: "https://auth.example/x", status: "waiting" }); + expect(stripAnsi(cardLines(s).join("\n"))).toContain( + "https://auth.example/x", + ); + s = browserUpdate(s, { status: "failed" }); + expect(cardKey(s, "y").startBrowser).toBe(true); // retry + s = browserUpdate(s, { status: "active" }); + expect(cardKey(s, "y").submit).toEqual({ action: "approved", input: {} }); + expect(cardKey(s, "n").submit).toEqual({ action: "rejected", input: {} }); + }); + + it("unknown kind: no approval path, only reject or later", () => { + const p: PendingInput = { + ...base, + tool: "future_form", + kind: "unknown", + title: "Fill the form", + }; + expect(cardKey(openCard(p), "y").submit).toBeUndefined(); + expect(cardKey(openCard(p), "n").submit).toEqual({ + action: "rejected", + input: {}, + }); + expect(stripAnsi(cardLines(openCard(p)).join("\n"))).toContain( + "needs the editor", + ); + }); + + it("list choice submits under the tool's answer key", () => { + const p: PendingInput = { + ...base, + tool: "select_payment_provider", + kind: "choice", + title: "Which provider?", + answerKey: "provider", + questions: [ + { + question: "Which provider?", + options: [{ label: "stripe" }, { label: "wix_payments" }], + multiSelect: false, + }, + ], + }; + let s = openCard(p); + s = cardKey(s, "down").state as typeof s; + expect(cardKey(s, "enter").submit).toEqual({ + action: "approved", + input: { provider: "wix_payments" }, + }); + }); }); diff --git a/packages/cli/tests/core/pending.spec.ts b/packages/cli/tests/core/pending.spec.ts index 8e1ea9382..21b58c9dd 100644 --- a/packages/cli/tests/core/pending.spec.ts +++ b/packages/cli/tests/core/pending.spec.ts @@ -164,6 +164,63 @@ describe("pendingInputs", () => { }); }); +describe("pendingInputs — more kinds", () => { + it("turns select_payment_provider into a single choice with the tool's answer key", () => { + const [p] = pendingInputs([ + parked("select_payment_provider", { + providers: ["stripe", "wix_payments"], + }), + ]); + expect(p.kind).toBe("choice"); + expect(p.answerKey).toBe("provider"); + expect(p.questions?.[0].options.map((o) => o.label)).toEqual([ + "stripe", + "wix_payments", + ]); + expect( + choiceAnswers(p.questions ?? [], [{ labels: ["stripe"] }], p.answerKey), + ).toEqual({ provider: "stripe" }); + }); + + it("carries the OAuth request so the CLI can start the connector flow itself", () => { + const [p] = pendingInputs([ + parked("request_oauth_authorization", { + integration_type: "wix", + scopes: ["stores.read"], + reason: "To list your products", + force_reconnect: true, + }), + ]); + expect(p).toMatchObject({ + kind: "browser", + title: "Authorize wix", + detail: "To list your products", + browser: { + flow: "connector", + integrationType: "wix", + scopes: ["stores.read"], + forceReconnect: true, + }, + }); + expect( + pendingInputs([parked("connect_github_account", {})])[0].browser, + ).toEqual({ flow: "github" }); + }); + + it("marks an unrenderable input/choice tool as unknown instead of yes/no", () => { + const msg = parked("some_future_form_tool", { summary: "Fill the form" }); + (msg.tool_calls as Record[])[0].waiting_on = { + kind: "input", + }; + expect(pendingInputs([msg])[0].kind).toBe("unknown"); + const approval = parked("some_future_approval_tool", { summary: "Do it?" }); + (approval.tool_calls as Record[])[0].waiting_on = { + kind: "approval", + }; + expect(pendingInputs([approval])[0].kind).toBe("approval"); + }); +}); + describe("choiceAnswers", () => { it("builds the web client's payload, skipping unanswered questions", () => { const questions = [ From 87284377243e81754dca4d2329b1a8f09809fc3f Mon Sep 17 00:00:00 2001 From: ayal Date: Sat, 19 Sep 2026 10:11:32 +0300 Subject: [PATCH 3/7] =?UTF-8?q?feat(cli):=20agent-friendly=20waiting=20con?= =?UTF-8?q?tract=20=E2=80=94=20the=20next=20`send`=20is=20the=20answer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the builder parks a tool call, non-interactive callers now learn about it and can answer without a browser: - `send`, `new` and `status` return status "waiting" with `pending`: the same shape the session's cards use (kind, title, questions and options, secret names, permission keys, browser step). `--stream-json` waiting events carry it too. - `send` takes the answer instead of a message: --approve / --reject / --skip / --choose