diff --git a/.gitignore b/.gitignore index 3eb76d3..575506c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ /node_modules /bun.lockb .DS_Store +.context/ diff --git a/README.md b/README.md index 8481f8c..ee8839a 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,21 @@ bee mcp serve-http [--port N] # local HTTP, 127.0.0.1, bearer-token auth Every read, search, and manage capability below is exposed as an MCP tool (`bee_search`, `bee_list_facts`, `bee_get_daily_summary`, …), so the CLI and your assistant work from the same data. +## Use Bee with AI Agents (CLI) + +If your prefer CLI for your Agent over MCP, the CLI provides structured JSON output, typed exit codes, command discovery, and pre-validation: + +```bash +export BEE_OUTPUT_FORMAT=json +bee --describe # discover all commands + parameters as JSON +bee validate facts list # pre-check before executing +bee facts list # structured JSON on stdout, errors on stderr +``` + +Exit codes: `0` success, `2` auth, `3` bad args, `4` network, `5` rate-limited. + +See **[docs/AGENT_USAGE.md](docs/AGENT_USAGE.md)** for the full agent protocol, command reference, and copy-paste prompts. + ## Installation Install from npm: diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..8050235 --- /dev/null +++ b/biome.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", + "organizeImports": { "enabled": true }, + "linter": { + "enabled": true, + "rules": { "recommended": true } + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2 + } +} diff --git a/bun.lock b/bun.lock index fbb8d03..baf0eb2 100644 --- a/bun.lock +++ b/bun.lock @@ -1,16 +1,15 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { - "name": "bee-cli", + "name": "@beeai/cli", "dependencies": { "date-fns": "^4.1.0", - "handlebars": "^4.7.8", "qrcode": "^1.5.4", "tweetnacl": "^1.0.3", }, "devDependencies": { + "@biomejs/biome": "1.9.4", "@types/qrcode": "^1.5.5", "bun-types": "^1.3.8", "typescript": "^5.7.3", @@ -18,90 +17,94 @@ }, }, "packages": { - "@types/node": ["@types/node@22.19.7", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/@types/node/-/node-22.19.7.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + "@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="], - "@types/qrcode": ["@types/qrcode@1.5.6", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/@types/qrcode/-/qrcode-1.5.6.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@1.9.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="], - "ansi-regex": ["ansi-regex@5.0.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/ansi-regex/-/ansi-regex-5.0.1.tgz", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@1.9.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="], - "ansi-styles": ["ansi-styles@4.3.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/ansi-styles/-/ansi-styles-4.3.0.tgz", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="], - "bun-types": ["bun-types@1.3.8", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/bun-types/-/bun-types-1.3.8.tgz", { "dependencies": { "@types/node": "*" } }, "sha512-fL99nxdOWvV4LqjmC+8Q9kW3M4QTtTR1eePs94v5ctGqU8OeceWrSUaRw3JYb7tU3FkMIAjkueehrHPPPGKi5Q=="], + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@1.9.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="], - "camelcase": ["camelcase@5.3.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/camelcase/-/camelcase-5.3.1.tgz", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="], - "cliui": ["cliui@6.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/cliui/-/cliui-6.0.0.tgz", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@1.9.4", "", { "os": "linux", "cpu": "x64" }, "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="], - "color-convert": ["color-convert@2.0.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/color-convert/-/color-convert-2.0.1.tgz", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@1.9.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="], - "color-name": ["color-name@1.1.4", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/color-name/-/color-name-1.1.4.tgz", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@1.9.4", "", { "os": "win32", "cpu": "x64" }, "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="], - "date-fns": ["date-fns@4.1.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/date-fns/-/date-fns-4.1.0.tgz", {}, "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg=="], + "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], - "decamelize": ["decamelize@1.2.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/decamelize/-/decamelize-1.2.0.tgz", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], - "dijkstrajs": ["dijkstrajs@1.0.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/dijkstrajs/-/dijkstrajs-1.0.3.tgz", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], - "emoji-regex": ["emoji-regex@8.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/emoji-regex/-/emoji-regex-8.0.0.tgz", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], - "find-up": ["find-up@4.1.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/find-up/-/find-up-4.1.0.tgz", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - "get-caller-file": ["get-caller-file@2.0.5", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/get-caller-file/-/get-caller-file-2.0.5.tgz", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], - "handlebars": ["handlebars@4.7.8", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/handlebars/-/handlebars-4.7.8.tgz", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], - "locate-path": ["locate-path@5.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/locate-path/-/locate-path-5.0.0.tgz", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], - "minimist": ["minimist@1.2.8", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/minimist/-/minimist-1.2.8.tgz", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="], - "neo-async": ["neo-async@2.6.2", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/neo-async/-/neo-async-2.6.2.tgz", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], - "p-limit": ["p-limit@2.3.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/p-limit/-/p-limit-2.3.0.tgz", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], - "p-locate": ["p-locate@4.1.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/p-locate/-/p-locate-4.1.0.tgz", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], - "p-try": ["p-try@2.2.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/p-try/-/p-try-2.2.0.tgz", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], - "path-exists": ["path-exists@4.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/path-exists/-/path-exists-4.0.0.tgz", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], - "pngjs": ["pngjs@5.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/pngjs/-/pngjs-5.0.0.tgz", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], - "qrcode": ["qrcode@1.5.4", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/qrcode/-/qrcode-1.5.4.tgz", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "require-directory": ["require-directory@2.1.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/require-directory/-/require-directory-2.1.1.tgz", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "require-main-filename": ["require-main-filename@2.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/require-main-filename/-/require-main-filename-2.0.0.tgz", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], - "set-blocking": ["set-blocking@2.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/set-blocking/-/set-blocking-2.0.0.tgz", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], - "source-map": ["source-map@0.6.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/source-map/-/source-map-0.6.1.tgz", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], - "string-width": ["string-width@4.2.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/string-width/-/string-width-4.2.3.tgz", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], - "strip-ansi": ["strip-ansi@6.0.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/strip-ansi/-/strip-ansi-6.0.1.tgz", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], - "tweetnacl": ["tweetnacl@1.0.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/tweetnacl/-/tweetnacl-1.0.3.tgz", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], - "typescript": ["typescript@5.9.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/typescript/-/typescript-5.9.3.tgz", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], - "uglify-js": ["uglify-js@3.19.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/uglify-js/-/uglify-js-3.19.3.tgz", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], - "undici-types": ["undici-types@6.21.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/undici-types/-/undici-types-6.21.0.tgz", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + "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=="], - "which-module": ["which-module@2.0.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/which-module/-/which-module-2.0.1.tgz", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "wordwrap": ["wordwrap@1.0.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/wordwrap/-/wordwrap-1.0.0.tgz", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "tweetnacl": ["tweetnacl@1.0.3", "", {}, "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw=="], - "wrap-ansi": ["wrap-ansi@6.2.0", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/wrap-ansi/-/wrap-ansi-6.2.0.tgz", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "y18n": ["y18n@4.0.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/y18n/-/y18n-4.0.3.tgz", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + "undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="], - "yargs": ["yargs@15.4.1", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/yargs/-/yargs-15.4.1.tgz", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], - "yargs-parser": ["yargs-parser@18.1.3", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/yargs-parser/-/yargs-parser-18.1.3.tgz", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "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=="], - "bun-types/@types/node": ["@types/node@20.19.30", "https://amazon-149122183214.d.codeartifact.us-west-2.amazonaws.com/npm/shared/@types/node/-/node-20.19.30.tgz", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g=="], + "y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + + "yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], } } diff --git a/docs/AGENT_USAGE.md b/docs/AGENT_USAGE.md new file mode 100644 index 0000000..574c44b --- /dev/null +++ b/docs/AGENT_USAGE.md @@ -0,0 +1,130 @@ +# Bee CLI — Agent Instructions + +Instructions for AI agents to use bee-cli autonomously via shell. + +## Quick Start (copy-paste to any agent) + +``` +You have access to the Bee CLI for managing personal AI data. + +Environment: + export BEE_OUTPUT_FORMAT=json + +Discover all commands: + bee --describe + +Pre-validate before writes: + bee validate + +Execute: + bee [subcommand] [--flags] + +Exit codes: 0=success, 2=auth, 3=bad args, 4=network, 5=rate-limit +On error, stderr contains: {"error":"...","code":N,"recoverable":bool,"suggestion":"..."} +``` + +## Installation + +```bash +npm install -g @beeai/cli +bee login +``` + +Or from source: + +```bash +git clone https://github.com/bee-computer/bee-cli.git +cd bee-cli +bun install +bun run build +./dist/bee login +``` + +## Protocol + +1. Run `bee --describe` once to learn available commands and their parameters +2. Run `bee validate ` before any create/update/delete +3. Parse stdout as JSON on exit code 0 +4. On non-zero exit, read stderr for structured error JSON + +## Exit Codes + +| Code | Meaning | Agent Action | +|------|---------|-------------| +| 0 | Success | Parse stdout JSON | +| 1 | General error | Report to user | +| 2 | Auth error | Run `bee login` | +| 3 | Invalid arguments | Fix flags, retry | +| 4 | Network/API error | Retry with backoff | +| 5 | Rate limited | Wait 60s, retry | + +## Commands + +### Read (no side effects) + +| Command | Description | +|---------|-------------| +| `bee facts list [--limit N] [--cursor C]` | List personal facts | +| `bee facts get ` | Get a single fact | +| `bee todos list [--limit N] [--cursor C]` | List todos | +| `bee todos get ` | Get a single todo | +| `bee conversations list [--limit N] [--cursor C]` | List conversations | +| `bee conversations get ` | Get conversation detail | +| `bee conversations transcript ` | Get full transcript | +| `bee daily list [--limit N] [--cursor C]` | List daily summaries | +| `bee daily get ` | Get daily summary detail | +| `bee journals list [--limit N] [--cursor C]` | List journal entries | +| `bee journals get ` | Get a journal entry | +| `bee search --query ` | Search across data | +| `bee today` | Today's brief | +| `bee now` | Recent conversations (last 10 hours) | +| `bee changed [--cursor C]` | Changes since last check | +| `bee activity [--limit N]` | Recent activity | +| `bee insights list [--limit N]` | AI insights | +| `bee locations [--limit N]` | Location history | +| `bee photos [--limit N]` | Photos with descriptions | +| `bee me` | User profile | +| `bee status --json` | Auth and connection status | + +### Write (validate first) + +| Command | Description | +|---------|-------------| +| `bee facts create --text ` | Create a fact | +| `bee facts update --text ` | Update a fact | +| `bee facts delete ` | Delete a fact | +| `bee todos create --text ` | Create a todo | +| `bee todos update [--text T] [--completed true\|false]` | Update a todo | +| `bee todos delete ` | Delete a todo | + +### Utility + +| Command | Description | +|---------|-------------| +| `bee --describe` | Full command schema with parameters (JSON) | +| `bee validate ` | Pre-validate without executing | +| `bee sync --output ` | Export to markdown files | +| `bee stream [--json]` | Real-time event stream (SSE) | +| `bee mcp` | Start MCP server (alternative agent interface) | +| `bee dashboard` | Interactive TUI (humans only) | + +## Pagination + +List commands return `next_cursor` in the response. Pass it back: +```bash +bee facts list --cursor "cursor_value_here" +``` + +## Error Recovery + +``` +Exit 2 (auth) → run bee login (needs human), then retry +Exit 3 (args) → fix command flags, retry immediately +Exit 4 (network) → exponential backoff: 1s, 2s, 4s, max 3 retries +Exit 5 (rate) → wait 60s, then retry +Exit 1 (general) → log and report to user +``` + +## Alternative: MCP Protocol + +For agents that support MCP natively, use `bee mcp` to start an MCP server instead of shell commands. MCP provides richer tool schemas and structured responses at the protocol level. diff --git a/package.json b/package.json index 46466eb..6d0fd3e 100644 --- a/package.json +++ b/package.json @@ -33,15 +33,17 @@ "prepack": "bun run build:lib && bun run build:all", "postinstall": "node ./scripts/postinstall.js", "release": "bash ./scripts/release.sh", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "lint": "biome check sources/", + "format": "biome format --write sources/" }, "dependencies": { "date-fns": "^4.1.0", - "handlebars": "^4.7.8", "qrcode": "^1.5.4", "tweetnacl": "^1.0.3" }, "devDependencies": { + "@biomejs/biome": "1.9.4", "@types/qrcode": "^1.5.5", "bun-types": "^1.3.8", "typescript": "^5.7.3" diff --git a/sources/commands/changed/index.ts b/sources/commands/changed/index.ts index bb568bc..b24132a 100644 --- a/sources/commands/changed/index.ts +++ b/sources/commands/changed/index.ts @@ -1,4 +1,5 @@ import type { Command, CommandContext } from "@/commands/types"; +import { ValidationError } from "@/errors"; import { printJson, requestClientJson } from "@/client/clientApi"; import { formatDateValue, @@ -103,7 +104,7 @@ function parseChangedArgs(args: readonly string[]): ChangedOptions { if (arg === "--cursor") { const value = args[i + 1]; if (value === undefined) { - throw new Error("--cursor requires a value"); + throw new ValidationError("--cursor requires a value"); } cursor = value; i += 1; @@ -111,14 +112,14 @@ function parseChangedArgs(args: readonly string[]): ChangedOptions { } if (arg.startsWith("-")) { - throw new Error(`Unknown option: ${arg}`); + throw new ValidationError(`Unknown option: ${arg}`); } positionals.push(arg); } if (positionals.length > 0) { - throw new Error(`Unexpected arguments: ${positionals.join(" ")}`); + throw new ValidationError(`Unexpected arguments: ${positionals.join(" ")}`); } const options: ChangedOptions = {}; diff --git a/sources/commands/login/index.ts b/sources/commands/login/index.ts index 45f6e36..62b7e4d 100644 --- a/sources/commands/login/index.ts +++ b/sources/commands/login/index.ts @@ -1,4 +1,5 @@ import type { Command, CommandContext } from "@/commands/types"; +import { ValidationError } from "@/errors"; import type { Environment } from "@/environment"; import { createDeveloperClient, createProxyClient } from "@/client"; import { @@ -125,7 +126,7 @@ async function handleLogin( } if (!token) { - throw new Error("Missing token."); + throw new ValidationError("Missing token."); } token = token.trim(); @@ -189,7 +190,7 @@ export function parseLoginArgs(args: readonly string[]): LoginOptions { if (arg === "--token") { const value = args[i + 1]; if (value === undefined) { - throw new Error("--token requires a value"); + throw new ValidationError("--token requires a value"); } token = value; i += 1; @@ -204,7 +205,7 @@ export function parseLoginArgs(args: readonly string[]): LoginOptions { if (arg === "--proxy") { const value = args[i + 1]; if (value === undefined) { - throw new Error("--proxy requires a value"); + throw new ValidationError("--proxy requires a value"); } proxy = value; i += 1; @@ -222,14 +223,14 @@ export function parseLoginArgs(args: readonly string[]): LoginOptions { } if (arg.startsWith("-")) { - throw new Error(`Unknown option: ${arg}`); + throw new ValidationError(`Unknown option: ${arg}`); } positionals.push(arg); } if (positionals.length > 0) { - throw new Error(`Unexpected arguments: ${positionals.join(" ")}`); + throw new ValidationError(`Unexpected arguments: ${positionals.join(" ")}`); } if (token && tokenStdin) { @@ -335,7 +336,7 @@ export async function validateProxyConnection( async function readTokenFromStdin(): Promise { if (process.stdin.isTTY) { - throw new Error("--token-stdin requires input via stdin."); + throw new ValidationError("--token-stdin requires input via stdin."); } const chunks: string[] = []; diff --git a/sources/commands/mcp/index.ts b/sources/commands/mcp/index.ts index dcc3b0a..dba9c4b 100644 --- a/sources/commands/mcp/index.ts +++ b/sources/commands/mcp/index.ts @@ -1,4 +1,5 @@ import type { Command } from "@/commands/types"; +import { ValidationError } from "@/errors"; import { serveMcpHttp, type McpHttpOptions } from "@/mcp/httpServer"; import { serveMcp } from "@/mcp/server"; import { @@ -31,7 +32,7 @@ export const mcpCommand: Command = { run: async (args, context) => { const [subcommand, ...remaining] = args; if (!subcommand) { - throw new Error("Missing MCP subcommand."); + throw new ValidationError("Missing MCP subcommand."); } if (subcommand === "serve") { diff --git a/sources/commands/stream/index.ts b/sources/commands/stream/index.ts index 655ab9f..fcbb54b 100644 --- a/sources/commands/stream/index.ts +++ b/sources/commands/stream/index.ts @@ -1,6 +1,5 @@ import type { Command, CommandContext } from "@/commands/types"; import { requireClientToken } from "@/client/clientApi"; -import Handlebars from "handlebars"; const SUPPORTED_EVENT_TYPES = [ // Conversations @@ -291,16 +290,19 @@ type WebhookPayload = { type WebhookConfig = { endpoint: string; - template: Handlebars.TemplateDelegate; + bodyTemplate: string; }; +function renderTemplate(template: string, vars: Record): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, key) => vars[key] ?? ""); +} + function buildWebhook(options: StreamOptions): WebhookConfig | null { if (!options.webhookEndpoint || !options.webhookBody) { return null; } - const template = Handlebars.compile(options.webhookBody, { noEscape: true }); - return { endpoint: options.webhookEndpoint, template }; + return { endpoint: options.webhookEndpoint, bodyTemplate: options.webhookBody }; } async function handleEvent( @@ -385,21 +387,19 @@ async function sendWebhook( webhook: WebhookConfig, payload: WebhookPayload ): Promise { - let body: string; - try { - body = webhook.template(payload); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.error(`Webhook template failed: ${message}`); - return; - } + const vars: Record = { + message: payload.message, + agentMessage: payload.agentMessage, + event: payload.event, + timestamp: payload.timestamp, + raw: payload.raw, + }; + const body = renderTemplate(webhook.bodyTemplate, vars); try { const response = await fetch(webhook.endpoint, { method: "POST", - headers: { - "Content-Type": "application/json", - }, + headers: { "Content-Type": "application/json" }, body, }); @@ -413,6 +413,7 @@ async function sendWebhook( } } + function formatEvent( eventType: string, data: Record, diff --git a/sources/commands/validate/index.test.ts b/sources/commands/validate/index.test.ts new file mode 100644 index 0000000..c8b9369 --- /dev/null +++ b/sources/commands/validate/index.test.ts @@ -0,0 +1,93 @@ +import { afterEach, describe, expect, it, spyOn } from "bun:test"; +import type { CommandContext } from "@/commands/types"; +import { createProxyClient } from "@/client"; +import { validateCommand, setCommandRegistry } from "./index"; + +type BunServer = ReturnType; + +const activeServers: BunServer[] = []; + +afterEach(() => { + for (const server of activeServers.splice(0, activeServers.length)) { + server.stop(true); + } +}); + +function createMockContext(server: BunServer): CommandContext { + return { + env: "prod", + client: createProxyClient("prod", { + address: `http://127.0.0.1:${server.port}`, + }), + }; +} + +describe("validate command", () => { + it("returns valid:true for known command with auth", async () => { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + activeServers.push(server); + + setCommandRegistry([ + { name: "facts", description: "test", usage: "test", run: async () => {} }, + ]); + + const logs: string[] = []; + const logSpy = spyOn(console, "log").mockImplementation((...args) => { + logs.push(args.join(" ")); + }); + + try { + await validateCommand.run(["facts"], createMockContext(server)); + } finally { + logSpy.mockRestore(); + } + + const parsed = JSON.parse(logs.join("")); + expect(parsed.valid).toBe(true); + }); + + it("returns valid:false for unknown command", async () => { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + activeServers.push(server); + + setCommandRegistry([ + { name: "facts", description: "test", usage: "test", run: async () => {} }, + ]); + + const logs: string[] = []; + const logSpy = spyOn(console, "log").mockImplementation((...args) => { + logs.push(args.join(" ")); + }); + + try { + await validateCommand.run(["boguscmd"], createMockContext(server)); + } finally { + logSpy.mockRestore(); + } + + const parsed = JSON.parse(logs.join("")); + expect(parsed.valid).toBe(false); + expect(parsed.reason).toContain("Unknown command"); + expect(parsed.code).toBe(3); + }); + + it("returns valid:false when no command specified", async () => { + const server = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + activeServers.push(server); + + const logs: string[] = []; + const logSpy = spyOn(console, "log").mockImplementation((...args) => { + logs.push(args.join(" ")); + }); + + try { + await validateCommand.run([], createMockContext(server)); + } finally { + logSpy.mockRestore(); + } + + const parsed = JSON.parse(logs.join("")); + expect(parsed.valid).toBe(false); + expect(parsed.reason).toBe("No command specified"); + }); +}); diff --git a/sources/commands/validate/index.ts b/sources/commands/validate/index.ts new file mode 100644 index 0000000..08b12e3 --- /dev/null +++ b/sources/commands/validate/index.ts @@ -0,0 +1,50 @@ +import type { Command, CommandContext } from "@/commands/types"; +import { loadToken } from "@/secureStore"; + +const USAGE = "bee validate [subcommand] [--flags...]"; + +export const validateCommand: Command = { + name: "validate", + description: "Pre-validate a command without executing it.", + usage: USAGE, + run: async (args, context) => { + await handleValidate(args, context); + }, +}; + +let registeredCommands: readonly Command[] = []; + +export function setCommandRegistry(cmds: readonly Command[]): void { + registeredCommands = cmds; +} + +async function handleValidate( + args: readonly string[], + context: CommandContext +): Promise { + if (args.length === 0) { + console.log(JSON.stringify({ valid: false, reason: "No command specified", code: 3 })); + process.exitCode = 3; + return; + } + + const [commandName] = args; + + const command = registeredCommands.find(c => c.name === commandName); + if (!command) { + console.log(JSON.stringify({ valid: false, reason: `Unknown command: ${commandName}`, code: 3 })); + process.exitCode = 3; + return; + } + + if (!context.client.isProxy) { + const token = await loadToken(context.env); + if (!token) { + console.log(JSON.stringify({ valid: false, reason: "Not authenticated", code: 2 })); + process.exitCode = 2; + return; + } + } + + console.log(JSON.stringify({ valid: true })); +} diff --git a/sources/errors.test.ts b/sources/errors.test.ts new file mode 100644 index 0000000..d6afe36 --- /dev/null +++ b/sources/errors.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "bun:test"; +import { BeeError, AuthError, ValidationError, ApiError, RateLimitError } from "./errors"; + +describe("error classes", () => { + it("AuthError has exit code 2 and is recoverable", () => { + const err = new AuthError("Not logged in"); + expect(err).toBeInstanceOf(BeeError); + expect(err.exitCode).toBe(2); + expect(err.recoverable).toBe(true); + expect(err.suggestion).toBe("Run bee login"); + expect(err.message).toBe("Not logged in"); + }); + + it("AuthError accepts custom suggestion", () => { + const err = new AuthError("Token expired", "Refresh your token"); + expect(err.suggestion).toBe("Refresh your token"); + }); + + it("ValidationError has exit code 3 and is not recoverable", () => { + const err = new ValidationError("Missing --text flag"); + expect(err).toBeInstanceOf(BeeError); + expect(err.exitCode).toBe(3); + expect(err.recoverable).toBe(false); + expect(err.message).toBe("Missing --text flag"); + }); + + it("ApiError has exit code 4 and is recoverable", () => { + const err = new ApiError("Server error"); + expect(err).toBeInstanceOf(BeeError); + expect(err.exitCode).toBe(4); + expect(err.recoverable).toBe(true); + expect(err.suggestion).toBe("Retry with backoff"); + }); + + it("RateLimitError has exit code 5 and is recoverable", () => { + const err = new RateLimitError("Too many requests"); + expect(err).toBeInstanceOf(BeeError); + expect(err.exitCode).toBe(5); + expect(err.recoverable).toBe(true); + expect(err.suggestion).toBe("Wait and retry"); + }); + + it("all errors have correct name property", () => { + expect(new AuthError("x").name).toBe("AuthError"); + expect(new ValidationError("x").name).toBe("ValidationError"); + expect(new ApiError("x").name).toBe("ApiError"); + expect(new RateLimitError("x").name).toBe("RateLimitError"); + }); +}); diff --git a/sources/errors.ts b/sources/errors.ts new file mode 100644 index 0000000..6840f73 --- /dev/null +++ b/sources/errors.ts @@ -0,0 +1,35 @@ +export class BeeError extends Error { + constructor( + message: string, + public readonly exitCode: number, + public readonly recoverable: boolean, + public readonly suggestion?: string + ) { + super(message); + this.name = this.constructor.name; + } +} + +export class AuthError extends BeeError { + constructor(message: string, suggestion?: string) { + super(message, 2, true, suggestion ?? "Run bee login"); + } +} + +export class ValidationError extends BeeError { + constructor(message: string) { + super(message, 3, false); + } +} + +export class ApiError extends BeeError { + constructor(message: string, suggestion?: string) { + super(message, 4, true, suggestion ?? "Retry with backoff"); + } +} + +export class RateLimitError extends BeeError { + constructor(message: string) { + super(message, 5, true, "Wait and retry"); + } +} diff --git a/sources/main.ts b/sources/main.ts index abba982..28ca6a7 100644 --- a/sources/main.ts +++ b/sources/main.ts @@ -1,4 +1,8 @@ import type { Command } from "@/commands/types"; +import { BeeError } from "@/errors"; +import { resolveOutputFormat } from "@/utils/format"; +import { loadToken } from "@/secureStore"; +import { validateCommand, setCommandRegistry } from "@/commands/validate"; import { activityCommand } from "@/commands/activity"; import { conversationsCommand } from "@/commands/conversations"; import { dailyCommand } from "@/commands/daily"; @@ -24,6 +28,7 @@ import { todayCommand } from "@/commands/today"; import { versionCommand } from "@/commands/version"; import type { Environment } from "@/environment"; import { createCommandContext } from "@/context"; +import { startDashboard } from "@/tui/dashboard"; const BIN = "bee"; @@ -49,10 +54,13 @@ const commands = [ syncCommand, proxyCommand, todosCommand, + validateCommand, pingCommand, versionCommand, ] satisfies readonly Command[]; +setCommandRegistry(commands); + const commandIndex = new Map(); for (const command of commands) { commandIndex.set(command.name, command); @@ -79,6 +87,7 @@ function printHelp(): void { console.log(` ${command.name} ${command.description}${aliasText}`); } + console.log(` dashboard Interactive TUI dashboard (alias: ui)`); console.log(""); console.log(`Run \"${BIN} --help\" for command-specific help.`); } @@ -105,6 +114,23 @@ async function runCli(): Promise { return; } + if (firstArg === "dashboard" || firstArg === "ui") { + await startDashboard(); + return; + } + + if (firstArg === "--describe") { + const { RESOURCES } = await import("@/resources"); + const token = await loadToken(parsed.env); + const blob = { + version: "0.7.1", + auth_status: token ? "valid" : "unauthenticated", + commands: buildDescribeBlob(RESOURCES), + }; + console.log(JSON.stringify(blob, null, 2)); + return; + } + const commandName = firstArg; const command = commandIndex.get(commandName); @@ -125,13 +151,30 @@ async function runCli(): Promise { const context = await createCommandContext(parsed.env); await command.run(commandArgs, context); } catch (error) { - if (error instanceof Error) { - console.error(error.message); + const { format } = resolveOutputFormat(commandArgs); + if (error instanceof BeeError) { + if (format === "text") { + console.error(error.message); + printCommandHelp(command); + } else { + console.error(JSON.stringify({ + error: error.message, + code: error.exitCode, + recoverable: error.recoverable, + suggestion: error.suggestion, + })); + } + process.exitCode = error.exitCode; } else { - console.error("Unexpected error"); + const msg = error instanceof Error ? error.message : "Unexpected error"; + if (format === "text") { + console.error(msg); + printCommandHelp(command); + } else { + console.error(JSON.stringify({ error: msg, code: 1, recoverable: false })); + } + process.exitCode = 1; } - printCommandHelp(command); - process.exitCode = 1; } } @@ -151,3 +194,51 @@ function parseGlobalArgs(args: readonly string[]): { env: Environment; args: str return { env, args: remaining }; } + +function buildDescribeBlob(resources: readonly import("@/resources/types").ResourceModule[]): Record { + const result: Record = {}; + + for (const resource of resources) { + const cliActions = resource.actions.filter(a => a.cli !== undefined); + if (cliActions.length === 0) continue; + + for (const action of cliActions) { + const cli = action.cli!; + const key = cli.subcommand + ? `${resource.cliCommand.name} ${cli.subcommand}` + : resource.cliCommand.name; + + const args: Array<{ name: string; type: string; required?: boolean }> = []; + + if (cli.positionals) { + for (const pos of cli.positionals) { + args.push({ name: pos.name, type: "positional", required: pos.required }); + } + } + + for (const flag of cli.flags) { + args.push({ name: flag.name, type: flag.kind }); + } + + const hasSideEffects = action.mcp?.name?.includes("create") || + action.mcp?.name?.includes("update") || + action.mcp?.name?.includes("delete"); + + result[key] = { + description: action.mcp?.description ?? resource.cliCommand.description, + args, + side_effects: hasSideEffects ?? false, + requires_auth: true, + }; + } + } + + // Add non-resource commands + result["validate"] = { description: "Pre-validate a command without executing", args: [{ name: "command", type: "positional", required: true }], side_effects: false, requires_auth: false }; + result["dashboard"] = { description: "Interactive TUI dashboard", args: [], side_effects: false, requires_auth: true }; + result["stream"] = { description: "Stream real-time events (SSE)", args: [{ name: "--types", type: "string" }, { name: "--json", type: "bool" }, { name: "--agent", type: "bool" }], side_effects: false, requires_auth: true }; + result["sync"] = { description: "Export data to markdown files", args: [{ name: "--output", type: "string" }, { name: "--only", type: "string" }], side_effects: false, requires_auth: true }; + result["mcp"] = { description: "Start MCP server for AI agents", args: [], side_effects: false, requires_auth: true }; + + return result; +} diff --git a/sources/tui/dashboard.ts b/sources/tui/dashboard.ts new file mode 100644 index 0000000..e76708d --- /dev/null +++ b/sources/tui/dashboard.ts @@ -0,0 +1,416 @@ +import { requestClientJson } from "@/client/clientApi"; +import { createCommandContext } from "@/context"; +import type { CommandContext } from "@/commands/types"; + +type MenuItem = { + label: string; + key: string; + fetch: (ctx: CommandContext) => Promise; +}; + +type ContentItem = { + title: string; + detail?: string; + color?: string; +}; + +const MENU_COL_WIDTH = 22; + +function stripAnsi(s: string): string { + return s.replace(/\x1b\[[0-9;]*m/g, ""); +} + +function padVisible(s: string, width: number): string { + const visible = stripAnsi(s).length; + const pad = Math.max(0, width - visible); + return s + " ".repeat(pad); +} + +function truncateVisible(s: string, maxWidth: number): string { + if (maxWidth <= 0) return ""; + const stripped = stripAnsi(s); + if (stripped.length <= maxWidth) return s; + + let visibleCount = 0; + let i = 0; + while (i < s.length && visibleCount < maxWidth - 1) { + if (s[i] === "\x1b") { + const end = s.indexOf("m", i); + if (end !== -1) { i = end + 1; continue; } + } + visibleCount++; + i++; + } + return s.slice(0, i) + "\x1b[0m"; +} + +const MENU_ITEMS: MenuItem[] = [ + { label: "Profile", key: "me", fetch: fetchProfile }, + { label: "Status", key: "status", fetch: fetchStatus }, + { label: "Facts", key: "facts", fetch: fetchFacts }, + { label: "Todos", key: "todos", fetch: fetchTodos }, + { label: "Conversations", key: "conversations", fetch: fetchConversations }, + { label: "Daily Summaries", key: "daily", fetch: fetchDaily }, + { label: "Journals", key: "journals", fetch: fetchJournals }, + { label: "Insights", key: "insights", fetch: fetchInsights }, + { label: "Today", key: "today", fetch: fetchToday }, + { label: "Now", key: "now", fetch: fetchNow }, + { label: "Changed", key: "changed", fetch: fetchChanged }, + { label: "Activity", key: "activity", fetch: fetchActivity }, + { label: "Locations", key: "locations", fetch: fetchLocations }, + { label: "Photos", key: "photos", fetch: fetchPhotos }, +]; + +async function fetchProfile(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/me", { method: "GET" }) as Record; + return [ + { title: `${data["first_name"]} ${data["last_name"]}`, detail: `ID: ${data["id"]}\nTimezone: ${data["timezone"]}`, color: "36" }, + ]; +} + +async function fetchStatus(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/status", { method: "GET" }) as Record; + return [ + { title: `Authenticated: ${data["authenticated"] ?? "unknown"}`, color: "32" }, + { title: `Environment: ${ctx.env}`, color: "36" }, + { title: `Proxy: ${ctx.client.isProxy ? "yes" : "no"}`, color: "90" }, + ]; +} + +async function fetchFacts(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/facts?limit=20", { method: "GET" }) as { facts: Array<{ id: number; text: string; tags: string[]; confirmed: boolean }> }; + return data.facts.map(f => ({ + title: `${f.confirmed ? "●" : "○"} ${f.text}`, + detail: `ID: ${f.id}\nTags: ${f.tags.join(", ") || "(none)"}\nConfirmed: ${f.confirmed}`, + color: f.confirmed ? "36" : "90", + })); +} + +async function fetchTodos(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/todos?limit=20", { method: "GET" }) as { todos: Array<{ id: number; text: string; completed: boolean; alarm_at: number | null; created_at: number }> }; + return data.todos.map(t => ({ + title: `${t.completed ? "●" : "○"} ${t.text}`, + detail: `ID: ${t.id}\nCompleted: ${t.completed}\nAlarm: ${t.alarm_at ? new Date(t.alarm_at).toLocaleString() : "(none)"}\nCreated: ${new Date(t.created_at).toLocaleString()}`, + color: t.completed ? "90" : "36", + })); +} + +async function fetchConversations(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/conversations?limit=10", { method: "GET" }) as { conversations: Array<{ id: number; short_summary: string | null; state: string; start_time: number; end_time: number | null }> }; + return data.conversations.map(conv => { + const date = new Date(conv.start_time).toLocaleDateString("en-CA"); + const summary = conv.short_summary ?? "(processing...)"; + return { + title: `[${date}] ${summary}`, + detail: `ID: ${conv.id}\nState: ${conv.state}\nStart: ${new Date(conv.start_time).toLocaleString()}\nEnd: ${conv.end_time ? new Date(conv.end_time).toLocaleString() : "(ongoing)"}`, + color: conv.state === "COMPLETED" ? "32" : conv.state === "CAPTURING" ? "33" : "90", + }; + }); +} + +async function fetchDaily(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/daily?limit=5", { method: "GET" }) as { daily_summaries: Array<{ id: number; short_summary: string; date_time: number }> }; + return data.daily_summaries.map(d => ({ + title: `[${new Date(d.date_time).toLocaleDateString("en-CA")}] ${d.short_summary.slice(0, 50)}`, + detail: `ID: ${d.id}\nDate: ${new Date(d.date_time).toLocaleDateString("en-CA")}\n\n${d.short_summary}`, + color: "36", + })); +} + +async function fetchJournals(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/journals?limit=10", { method: "GET" }) as { journals: Array<{ id: string; text: string; state: string; created_at: number }> }; + return data.journals.map(j => ({ + title: `[${j.state}] ${j.text.slice(0, 40)}`, + detail: `ID: ${j.id}\nState: ${j.state}\nCreated: ${new Date(j.created_at).toLocaleString()}\n\n${j.text}`, + color: "36", + })); +} + +async function fetchInsights(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/insights", { method: "GET" }) as { insights: Array<{ id: number; title: string; category: string; response: string | null }> }; + if (data.insights.length === 0) return [{ title: "(no insights)", color: "90" }]; + return data.insights.map(i => ({ + title: `[${i.category}] ${i.title}`, + detail: `ID: ${i.id}\nCategory: ${i.category}\n\n${i.response ?? "(no response)"}`, + color: "35", + })); +} + +async function fetchToday(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/todayBrief", { method: "GET" }) as { calendar_events: unknown[]; emails: unknown[]; timezone: string }; + return [ + { title: `Calendar: ${data.calendar_events.length} events`, color: "36" }, + { title: `Emails: ${data.emails.length} messages`, color: "33" }, + { title: `Timezone: ${data.timezone}`, color: "90" }, + ]; +} + +async function fetchNow(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/conversations?limit=5", { method: "GET" }) as { conversations: Array<{ short_summary: string | null; state: string; start_time: number }> }; + const recent = data.conversations.filter(conv => conv.start_time > Date.now() - 36000000); + if (recent.length === 0) return [{ title: "(no conversations in last 10h)", color: "90" }]; + return recent.map(conv => ({ + title: `[${new Date(conv.start_time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false })}] ${conv.short_summary ?? "(processing...)"}`, + color: conv.state === "COMPLETED" ? "32" : "33", + })); +} + +async function fetchChanged(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/changes", { method: "GET" }) as { meta: { since: number; until: number }; facts: Array<{ text: string }>; todos: Array<{ text: string }>; conversations: unknown[]; dailies: unknown[]; journals: unknown[] }; + const items: ContentItem[] = []; + items.push({ title: `Period: ${new Date(data.meta.since).toLocaleString()} → ${new Date(data.meta.until).toLocaleString()}`, color: "90" }); + for (const f of data.facts) items.push({ title: `[fact] ${f.text}`, color: "36" }); + for (const t of data.todos) items.push({ title: `[todo] ${t.text}`, color: "33" }); + if (data.conversations.length > 0) items.push({ title: `[conversations] ${data.conversations.length} changed`, color: "35" }); + if (data.dailies.length > 0) items.push({ title: `[dailies] ${data.dailies.length} changed`, color: "32" }); + if (data.journals.length > 0) items.push({ title: `[journals] ${data.journals.length} changed`, color: "90" }); + return items; +} + +async function fetchActivity(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/activity?limit=10", { method: "GET" }) as { activities: Array<{ id: number; type: string; summary: string; timestamp: number }> }; + if (!data.activities || data.activities.length === 0) return [{ title: "(no recent activity)", color: "90" }]; + return data.activities.map(a => ({ + title: `[${a.type}] ${a.summary}`, + detail: `ID: ${a.id}\nType: ${a.type}\nTime: ${new Date(a.timestamp).toLocaleString()}`, + color: "36", + })); +} + +async function fetchLocations(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/locations?limit=10", { method: "GET" }) as { locations: Array<{ id: number; name: string; latitude: number; longitude: number; timestamp: number }> }; + if (!data.locations || data.locations.length === 0) return [{ title: "(no locations)", color: "90" }]; + return data.locations.map(l => ({ + title: `${l.name}`, + detail: `ID: ${l.id}\nCoords: ${l.latitude}, ${l.longitude}\nTime: ${new Date(l.timestamp).toLocaleString()}`, + color: "36", + })); +} + +async function fetchPhotos(ctx: CommandContext): Promise { + const data = await requestClientJson(ctx, "/v1/photos?limit=10", { method: "GET" }) as { photos: Array<{ id: string; description: string; timestamp: number }> }; + if (!data.photos || data.photos.length === 0) return [{ title: "(no photos)", color: "90" }]; + return data.photos.map(p => ({ + title: `${p.description.slice(0, 50)}`, + detail: `ID: ${p.id}\nTime: ${new Date(p.timestamp).toLocaleString()}\n\n${p.description}`, + color: "36", + })); +} + +type Pane = "menu" | "content"; + +export async function startDashboard(): Promise { + if (!process.stdin.isTTY) { + console.error("Dashboard requires an interactive terminal (TTY)."); + process.exitCode = 1; + return; + } + + let menuIndex = 0; + let contentIndex = 0; + let contentScroll = 0; + let contentItems: ContentItem[] = []; + let expandedIndex: number | null = null; + let activePane: Pane = "menu"; + let loading = false; + + const ctx = await createCommandContext("prod"); + + const render = () => { + const cols = process.stdout.columns ?? 80; + const rows = process.stdout.rows ?? 24; + const contentMaxWidth = cols - MENU_COL_WIDTH - 4; + const maxBodyRows = rows - 4; + + process.stdout.write("\x1b[H\x1b[2J"); + + // Header + const menuHighlight = activePane === "menu" ? "\x1b[1;37m" : "\x1b[90m"; + const contentHighlight = activePane === "content" ? "\x1b[1;37m" : "\x1b[90m"; + process.stdout.write(`\u{1F41D} ${menuHighlight}Bee Dashboard\x1b[0m ${contentHighlight}│ Content\x1b[0m \x1b[90mTab switch ↑↓ navigate Enter expand q quit\x1b[0m\n`); + process.stdout.write(`\x1b[90m${"─".repeat(cols)}\x1b[0m\n`); + + // Build content lines + const contentLines: string[] = []; + if (contentItems.length === 0) { + contentLines.push("\x1b[90mPress Enter to load...\x1b[0m"); + } else { + for (let i = 0; i < contentItems.length; i++) { + const item = contentItems[i]!; + const isSelected = activePane === "content" && i === contentIndex; + const prefix = isSelected ? "\x1b[36;1m ▸ " : `\x1b[${item.color ?? "37"}m `; + contentLines.push(`${prefix}${item.title}\x1b[0m`); + + if (expandedIndex === i && item.detail) { + const detailLines = item.detail.split("\n"); + for (const dl of detailLines) { + contentLines.push(`\x1b[90m ${dl}\x1b[0m`); + } + contentLines.push(""); + } + } + } + + // Apply scroll to content + const visibleContent = contentLines.slice(contentScroll, contentScroll + maxBodyRows); + const totalRows = Math.max(MENU_ITEMS.length, visibleContent.length); + + for (let i = 0; i < Math.min(totalRows, maxBodyRows); i++) { + let menuCell = ""; + if (i < MENU_ITEMS.length) { + const item = MENU_ITEMS[i]!; + if (i === menuIndex) { + const highlight = activePane === "menu" ? "\x1b[36;1m" : "\x1b[37m"; + menuCell = `${highlight} ▸ ${item.label}\x1b[0m`; + } else { + menuCell = `\x1b[90m ${item.label}\x1b[0m`; + } + } + + const contentCell = i < visibleContent.length ? truncateVisible(visibleContent[i] ?? "", contentMaxWidth) : ""; + process.stdout.write(`${padVisible(menuCell, MENU_COL_WIDTH)} \x1b[90m│\x1b[0m ${contentCell}\n`); + } + + // Footer + process.stdout.write(`\x1b[90m${"─".repeat(cols)}\x1b[0m\n`); + if (loading) { + process.stdout.write(` \x1b[33m⟳ Loading...\x1b[0m\n`); + } else { + const paneLabel = activePane === "menu" ? "Menu" : "Content"; + const itemCount = contentItems.length > 0 ? `${contentItems.length} items` : "empty"; + const scrollInfo = contentLines.length > maxBodyRows ? ` scroll ${contentScroll + 1}/${contentLines.length}` : ""; + process.stdout.write(` \x1b[32m●\x1b[0m \x1b[90m${MENU_ITEMS[menuIndex]!.label} | ${paneLabel} | ${itemCount}${scrollInfo}\x1b[0m\n`); + } + }; + + const loadContent = async () => { + loading = true; + expandedIndex = null; + contentIndex = 0; + contentScroll = 0; + render(); + try { + contentItems = await MENU_ITEMS[menuIndex]!.fetch(ctx); + } catch (error) { + const msg = error instanceof Error ? error.message : "Unknown error"; + contentItems = [{ title: `Error: ${msg}`, color: "31" }]; + } + loading = false; + render(); + }; + + process.stdout.write("\x1b[?25l"); + render(); + + // Re-render on terminal resize + process.stdout.on("resize", () => render()); + + const stdin = process.stdin; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding("utf8"); + + const cleanup = () => { + stdin.setRawMode(false); + stdin.pause(); + process.stdout.removeAllListeners("resize"); + process.stdout.write("\x1b[?25h"); + process.stdout.write("\x1b[H\x1b[2J"); + }; + + stdin.on("data", async (key: string) => { + if (key === "q" || key === "\x03") { + cleanup(); + process.exit(0); + } + + // Tab - switch panes + if (key === "\t") { + activePane = activePane === "menu" ? "content" : "menu"; + render(); + return; + } + + // Escape - collapse expanded or switch to menu + if (key === "\x1b" && expandedIndex !== null) { + expandedIndex = null; + render(); + return; + } + if (key === "\x1b") { + activePane = "menu"; + render(); + return; + } + + // Arrow up + if (key === "\x1b[A" || key === "k") { + if (activePane === "menu") { + menuIndex = (menuIndex - 1 + MENU_ITEMS.length) % MENU_ITEMS.length; + } else { + if (contentIndex > 0) contentIndex--; + adjustScroll(); + } + render(); + return; + } + + // Arrow down + if (key === "\x1b[B" || key === "j") { + if (activePane === "menu") { + menuIndex = (menuIndex + 1) % MENU_ITEMS.length; + } else { + if (contentIndex < contentItems.length - 1) contentIndex++; + adjustScroll(); + } + render(); + return; + } + + // Enter + if (key === "\r" || key === "\n") { + if (activePane === "menu") { + await loadContent(); + activePane = "content"; + render(); + } else { + if (contentItems[contentIndex]?.detail) { + expandedIndex = expandedIndex === contentIndex ? null : contentIndex; + render(); + } + } + return; + } + + // Arrow right - switch to content + if (key === "\x1b[C" || key === "l") { + if (activePane === "menu" && contentItems.length > 0) { + activePane = "content"; + render(); + } + return; + } + + // Arrow left - switch to menu + if (key === "\x1b[D" || key === "h") { + activePane = "menu"; + expandedIndex = null; + render(); + return; + } + }); + + function adjustScroll() { + const rows = process.stdout.rows ?? 24; + const maxBodyRows = rows - 4; + let linesBefore = 0; + for (let i = 0; i < contentIndex; i++) { + linesBefore++; + if (expandedIndex === i && contentItems[i]?.detail) { + linesBefore += contentItems[i]!.detail!.split("\n").length + 1; + } + } + if (linesBefore < contentScroll) contentScroll = linesBefore; + if (linesBefore >= contentScroll + maxBodyRows) contentScroll = linesBefore - maxBodyRows + 1; + } +} diff --git a/sources/tui/index.ts b/sources/tui/index.ts new file mode 100644 index 0000000..4b6774a --- /dev/null +++ b/sources/tui/index.ts @@ -0,0 +1,16 @@ +export { + ansi, + shouldUseTui, + createSpinner, + renderFactsList, + renderTodosList, + renderConversationsList, + renderDevicesList, + renderSearchResults, + renderProfile, + renderStatus, + renderError, + renderDailySummary, + renderChanged, +} from "./renderer"; +export type { TuiTheme, PaginationInfo } from "./renderer"; diff --git a/sources/tui/renderer.ts b/sources/tui/renderer.ts new file mode 100644 index 0000000..efa1f31 --- /dev/null +++ b/sources/tui/renderer.ts @@ -0,0 +1,468 @@ +import type { OutputFormat } from "@/utils/format"; + +export type TuiTheme = { + primary: string; + secondary: string; + success: string; + warning: string; + error: string; + muted: string; + heading: string; +}; + +const THEME: TuiTheme = { + primary: "36", + secondary: "35", + success: "32", + warning: "33", + error: "31", + muted: "90", + heading: "1;37", +}; + +const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +const SPINNER_FRAMES_ASCII = ["|", "/", "-", "\\"]; + +type TerminalCaps = { + color: "none" | "16" | "256" | "truecolor"; + hyperlinks: boolean; + unicode: boolean; + italic: boolean; +}; + +function detectCapabilities(): TerminalCaps { + const term = process.env["TERM"] ?? ""; + const colorterm = process.env["COLORTERM"] ?? ""; + const termProgram = process.env["TERM_PROGRAM"] ?? ""; + + // Hyperlink support (OSC 8) + const hyperlinkTerminals = [ + "iterm2", "iterm.app", + "wezterm", + "ghostty", + "windows terminal", "windowsterminal", + "vscode", + "rio", + "alacritty", + "contour", + "foot", + "kitty", + ]; + const hyperlinks = hyperlinkTerminals.some(t => + termProgram.toLowerCase().includes(t) || + term.toLowerCase().includes(t) + ) || process.env["TERM_PROGRAM_VERSION"]?.includes("WezTerm") === true; + + // Color depth + let color: TerminalCaps["color"] = "16"; + if (colorterm === "truecolor" || colorterm === "24bit") { + color = "truecolor"; + } else if (term.includes("256color") || colorterm === "256color") { + color = "256"; + } else if (term === "linux" || term === "dumb") { + color = term === "dumb" ? "none" : "16"; + } + + // macOS Terminal.app: 256 color max, no truecolor + if (termProgram === "Apple_Terminal") { + color = "256"; + } + + // Unicode support (linux console is the main exception) + const unicode = term !== "linux" && term !== "dumb"; + + // Italic support + const italic = term !== "linux" && term !== "dumb" && termProgram !== "Apple_Terminal"; + + return { color, hyperlinks, unicode, italic }; +} + +let _caps: TerminalCaps | null = null; +function getCaps(): TerminalCaps { + if (!_caps) _caps = detectCapabilities(); + return _caps; +} + +function noColorEnabled(): boolean { + return process.env["NO_COLOR"] !== undefined || + process.env["BEE_NO_COLOR"] === "1" || + getCaps().color === "none"; +} + +function c(code: string, text: string): string { + if (noColorEnabled()) return text; + return `\x1b[${code}m${text}\x1b[0m`; +} + +function dim(text: string): string { + return c(THEME.muted, text); +} + +function bold(text: string): string { + return c("1", text); +} + +function italic(text: string): string { + if (!getCaps().italic) return text; + return c("3", text); +} + +function underline(text: string): string { + return c("4", text); +} + +function strikethrough(text: string): string { + return c("9", text); +} + +function link(url: string, label?: string): string { + if (noColorEnabled()) return label ? `${label} (${url})` : url; + const display = label ?? url; + if (!getCaps().hyperlinks) { + return label ? `${c(THEME.primary, underline(display))} ${dim(`(${url})`)}` : c(THEME.primary, underline(url)); + } + return `\x1b]8;;${url}\x1b\\${c(THEME.primary, underline(display))}\x1b]8;;\x1b\\`; +} + +function bg(code: string, text: string): string { + if (noColorEnabled()) return text; + return `\x1b[${code}m${text}\x1b[0m`; +} + +function rgb(r: number, g: number, b: number, text: string): string { + if (noColorEnabled()) return text; + const caps = getCaps(); + if (caps.color === "truecolor") { + return `\x1b[38;2;${r};${g};${b}m${text}\x1b[0m`; + } + // Fallback: map to closest 256-color or 16-color ANSI + if (caps.color === "256") { + const code = rgbTo256(r, g, b); + return `\x1b[38;5;${code}m${text}\x1b[0m`; + } + // 16-color fallback: use closest basic color + return c(rgbTo16(r, g, b), text); +} + +function bgRgb(r: number, g: number, b: number, text: string): string { + if (noColorEnabled()) return text; + const caps = getCaps(); + if (caps.color === "truecolor") { + return `\x1b[48;2;${r};${g};${b}m${text}\x1b[0m`; + } + if (caps.color === "256") { + const code = rgbTo256(r, g, b); + return `\x1b[48;5;${code}m${text}\x1b[0m`; + } + return text; +} + +function rgbTo256(r: number, g: number, b: number): number { + if (r === g && g === b) { + if (r < 8) return 16; + if (r > 248) return 231; + return Math.round((r - 8) / 247 * 24) + 232; + } + return 16 + (36 * Math.round(r / 255 * 5)) + (6 * Math.round(g / 255 * 5)) + Math.round(b / 255 * 5); +} + +function rgbTo16(r: number, g: number, b: number): string { + const brightness = (r + g + b) / 3; + if (brightness < 64) return "30"; + if (r > g && r > b) return brightness > 180 ? "91" : "31"; + if (g > r && g > b) return brightness > 180 ? "92" : "32"; + if (b > r && b > g) return brightness > 180 ? "94" : "34"; + if (r > 200 && g > 200) return "93"; + if (r > 200 && b > 200) return "95"; + if (g > 200 && b > 200) return "96"; + return brightness > 180 ? "97" : "37"; +} + +function stripAnsi(s: string): string { + return s.replace(/\x1b\]8;;[^\x1b]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, ""); +} + +function header(title: string, subtitle?: string): string { + const cols = process.stdout.columns ?? 80; + const sub = subtitle ? ` ${dim(subtitle)}` : ""; + const line = dim("─".repeat(Math.max(0, cols - stripAnsi(title).length - stripAnsi(sub).length - 2))); + return `${c(THEME.heading, title)}${sub} ${line}`; +} + +function footer(info?: string): string { + if (!info) return ""; + return dim(info); +} + +export const ansi = { + c, + dim, + bold, + italic, + underline, + strikethrough, + link, + bg, + rgb, + bgRgb, + stripAnsi, + noColorEnabled, + getCaps, +}; + +export function shouldUseTui(format: OutputFormat): boolean { + if (format === "json" || format === "minimal") return false; + if (process.env["BEE_NO_TUI"] === "1") return false; + if (!process.stdout.isTTY) return false; + return true; +} + +export type PaginationInfo = { + showing: number; + total?: number; + hasMore: boolean; + cursor?: string | null; +}; + +function paginationHint(info: PaginationInfo): string { + if (!info.hasMore) return ""; + const totalStr = info.total ? ` of ${info.total}` : "+"; + return dim(` ↓ showing ${info.showing}${totalStr} — use --limit N or --all for more`); +} + +export function createSpinner(message: string): { start: () => void; stop: (finalMessage?: string) => void } { + if (!process.stdout.isTTY || noColorEnabled()) { + return { + start: () => process.stderr.write(`${message}...\n`), + stop: (final?: string) => { if (final) process.stderr.write(`${final}\n`); }, + }; + } + + let frameIndex = 0; + let interval: ReturnType | null = null; + const frames = getCaps().unicode ? SPINNER_FRAMES : SPINNER_FRAMES_ASCII; + + return { + start: () => { + process.stderr.write(`\x1b[?25l`); + interval = setInterval(() => { + const frame = frames[frameIndex % frames.length]; + process.stderr.write(`\r${c(THEME.primary, frame!)} ${message}`); + frameIndex++; + }, 80); + }, + stop: (final?: string) => { + if (interval) clearInterval(interval); + process.stderr.write(`\r\x1b[2K`); + process.stderr.write(`\x1b[?25h`); + if (final) process.stderr.write(`${c(THEME.success, "✓")} ${final}\n`); + }, + }; +} + +export function renderFactsList( + facts: Array<{ id: number; text: string; tags: string[]; confirmed: boolean }>, + pagination?: PaginationInfo +): void { + const confirmed = facts.filter(f => f.confirmed); + const pending = facts.filter(f => !f.confirmed); + + console.log(header("Facts", `${facts.length} items`)); + console.log(""); + + if (confirmed.length > 0) { + console.log(c(THEME.success, ` ▸ Confirmed (${confirmed.length})`)); + for (const fact of confirmed) { + const tags = fact.tags.length > 0 ? dim(` [${fact.tags.join(", ")}]`) : ""; + console.log(` ${c(THEME.primary, fact.text)}${tags}`); + } + console.log(""); + } + + if (pending.length > 0) { + console.log(c(THEME.warning, ` ▸ Pending (${pending.length})`)); + for (const fact of pending) { + console.log(` ${dim(fact.text)}`); + } + console.log(""); + } + + if (facts.length === 0) { + console.log(dim(" (none)")); + console.log(""); + } + + if (pagination) console.log(paginationHint(pagination)); +} + +export function renderTodosList( + todos: Array<{ id: number; text: string; completed: boolean; alarm_at: number | null }>, + pagination?: PaginationInfo +): void { + const open = todos.filter(t => !t.completed); + const done = todos.filter(t => t.completed); + + console.log(header("Todos", `${open.length} open, ${done.length} done`)); + console.log(""); + + if (open.length > 0) { + for (const todo of open) { + const alarm = todo.alarm_at ? dim(" ⏰") : ""; + console.log(` ${c(THEME.primary, "○")} ${todo.text}${alarm}`); + } + console.log(""); + } + + if (done.length > 0) { + console.log(dim(` ── completed ──`)); + for (const todo of done) { + console.log(` ${dim("●")} ${dim(todo.text)}`); + } + console.log(""); + } + + if (todos.length === 0) { + console.log(dim(" (none)")); + console.log(""); + } + + if (pagination) console.log(paginationHint(pagination)); +} + +export function renderConversationsList( + conversations: Array<{ id: number; short_summary: string | null; state: string; start_time: number }>, + pagination?: PaginationInfo +): void { + console.log(header("Conversations", `${conversations.length} items`)); + console.log(""); + + if (conversations.length === 0) { + console.log(dim(" (none)")); + } else { + for (const conv of conversations) { + const date = new Date(conv.start_time).toLocaleDateString("en-CA"); + const time = new Date(conv.start_time).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", hour12: false }); + const stateColor = conv.state === "COMPLETED" ? THEME.success : conv.state === "CAPTURING" ? THEME.warning : THEME.muted; + const summary = conv.short_summary ?? "(processing...)"; + console.log(` ${dim(`${date} ${time}`)} ${c(stateColor, conv.state.toLowerCase())} ${c(THEME.primary, summary)}`); + } + } + console.log(""); + + if (pagination) console.log(paginationHint(pagination)); +} + +export function renderDevicesList( + devices: Array<{ id: string; name: string; vendor: string; model: string; type: string; active: boolean }> +): void { + console.log(header("Devices", `${devices.length} connected`)); + console.log(""); + + if (devices.length === 0) { + console.log(dim(" (none)")); + } else { + for (const device of devices) { + const dot = device.active ? c(THEME.success, "●") : c(THEME.error, "○"); + console.log(` ${dot} ${c(THEME.primary, device.name)} ${dim(`${device.vendor} ${device.model}`)} ${c(THEME.secondary, device.type)}`); + } + } + console.log(""); +} + +export function renderSearchResults( + results: Array<{ id: string; type: string; score: number; title_snippet?: string; snippet?: string }> +): void { + console.log(header("Search", `${results.length} results`)); + console.log(""); + + if (results.length === 0) { + console.log(dim(" No results found.")); + } else { + for (const result of results) { + const scoreBar = c(THEME.success, "█".repeat(Math.min(8, Math.round(result.score * 6)))); + const title = (result.title_snippet ?? result.id).replace(/\*\*/g, ""); + console.log(` ${c(THEME.secondary, `[${result.type}]`)} ${c(THEME.primary, title)} ${scoreBar}`); + if (result.snippet) { + const clean = result.snippet.replace(/\*\*/g, "").slice(0, 90); + console.log(` ${dim(clean)}`); + } + console.log(""); + } + } +} + +export function renderProfile(profile: { id: number; first_name: string; last_name: string; timezone: string }): void { + console.log(header("Profile")); + console.log(""); + console.log(` ${dim("Name")} ${c(THEME.primary, `${profile.first_name} ${profile.last_name}`)}`); + console.log(` ${dim("ID")} ${c(THEME.primary, String(profile.id))}`); + console.log(` ${dim("Timezone")} ${c(THEME.primary, profile.timezone)}`); + console.log(""); +} + +export function renderStatus(status: { authenticated: boolean; user_id: number | null; environment: string; api_reachable: boolean; version: string }): void { + const authIcon = status.authenticated ? c(THEME.success, "●") : c(THEME.error, "○"); + const apiIcon = status.api_reachable ? c(THEME.success, "●") : c(THEME.error, "○"); + + console.log(header("Status")); + console.log(""); + console.log(` ${dim("Auth")} ${authIcon} ${status.authenticated ? "authenticated" : "not authenticated"}`); + console.log(` ${dim("User")} ${c(THEME.primary, String(status.user_id ?? "—"))}`); + console.log(` ${dim("Env")} ${c(THEME.primary, status.environment)}`); + console.log(` ${dim("API")} ${apiIcon} ${status.api_reachable ? "reachable" : "unreachable"}`); + console.log(` ${dim("Version")} ${c(THEME.primary, status.version)}`); + console.log(""); +} + +export function renderError(error: { message: string; code: number; recoverable: boolean; suggestion?: string }): void { + console.error(""); + console.error(` ${c(THEME.error, "✗")} ${error.message}`); + console.error(` ${dim(`exit ${error.code}`)}${error.recoverable ? dim(" (recoverable)") : ""}`); + if (error.suggestion) { + console.error(` ${c(THEME.success, "→")} ${error.suggestion}`); + } + console.error(""); +} + +export function renderDailySummary(daily: { id: number; short_summary: string; date_time: number }): void { + const date = new Date(daily.date_time).toLocaleDateString("en-CA"); + console.log(header("Daily Summary", date)); + console.log(""); + console.log(` ${daily.short_summary}`); + console.log(""); + console.log(footer(dim(`id: ${daily.id}`))); +} + +export function renderChanged( + meta: { since: number; until: number; updated: boolean; next_cursor: string | null }, + counts: { facts: number; todos: number; conversations: number; dailies: number; journals: number } +): void { + const since = new Date(meta.since).toLocaleString(); + const until = new Date(meta.until).toLocaleString(); + const total = counts.facts + counts.todos + counts.conversations + counts.dailies + counts.journals; + + console.log(header("Changed", `${total} updates`)); + console.log(""); + console.log(` ${dim("Period")} ${c(THEME.primary, since)} → ${c(THEME.primary, until)}`); + console.log(""); + + const items = [ + { label: "Facts", count: counts.facts, color: THEME.primary }, + { label: "Todos", count: counts.todos, color: THEME.warning }, + { label: "Conversations", count: counts.conversations, color: THEME.secondary }, + { label: "Dailies", count: counts.dailies, color: THEME.success }, + { label: "Journals", count: counts.journals, color: THEME.muted }, + ]; + + for (const item of items) { + if (item.count > 0) { + const bar = c(item.color, "█".repeat(Math.min(15, item.count))); + console.log(` ${dim(item.label.padEnd(14))} ${bar} ${dim(String(item.count))}`); + } + } + console.log(""); + + if (meta.next_cursor) { + console.log(footer(dim(`cursor: ${meta.next_cursor}`))); + } +} diff --git a/sources/utils/format.test.ts b/sources/utils/format.test.ts new file mode 100644 index 0000000..c643b5d --- /dev/null +++ b/sources/utils/format.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "bun:test"; +import { resolveOutputFormat } from "./format"; + +describe("resolveOutputFormat", () => { + it("defaults to text when no flag or env", () => { + const original = process.env["BEE_OUTPUT_FORMAT"]; + delete process.env["BEE_OUTPUT_FORMAT"]; + const { format, args } = resolveOutputFormat(["list", "--limit", "5"]); + expect(format).toBe("text"); + expect(args).toEqual(["list", "--limit", "5"]); + if (original) process.env["BEE_OUTPUT_FORMAT"] = original; + }); + + it("detects --json flag", () => { + const { format, args } = resolveOutputFormat(["list", "--json", "--limit", "5"]); + expect(format).toBe("json"); + expect(args).toEqual(["list", "--limit", "5"]); + }); + + it("detects --pretty flag", () => { + const { format, args } = resolveOutputFormat(["list", "--pretty"]); + expect(format).toBe("text"); + expect(args).toEqual(["list"]); + }); + + it("detects --minimal flag", () => { + const { format, args } = resolveOutputFormat(["--minimal", "list"]); + expect(format).toBe("minimal"); + expect(args).toEqual(["list"]); + }); + + it("detects --format json", () => { + const { format, args } = resolveOutputFormat(["--format", "json", "list"]); + expect(format).toBe("json"); + expect(args).toEqual(["list"]); + }); + + it("flag takes precedence over env var", () => { + const original = process.env["BEE_OUTPUT_FORMAT"]; + process.env["BEE_OUTPUT_FORMAT"] = "json"; + const { format } = resolveOutputFormat(["--pretty", "list"]); + expect(format).toBe("text"); + if (original) process.env["BEE_OUTPUT_FORMAT"] = original; + else delete process.env["BEE_OUTPUT_FORMAT"]; + }); + + it("reads BEE_OUTPUT_FORMAT env var", () => { + const original = process.env["BEE_OUTPUT_FORMAT"]; + process.env["BEE_OUTPUT_FORMAT"] = "minimal"; + const { format } = resolveOutputFormat(["list"]); + expect(format).toBe("minimal"); + if (original) process.env["BEE_OUTPUT_FORMAT"] = original; + else delete process.env["BEE_OUTPUT_FORMAT"]; + }); +}); diff --git a/sources/utils/format.ts b/sources/utils/format.ts new file mode 100644 index 0000000..49c4b1f --- /dev/null +++ b/sources/utils/format.ts @@ -0,0 +1,52 @@ +export type OutputFormat = "json" | "text" | "minimal"; + +export function resolveOutputFormat(args: readonly string[]): { + format: OutputFormat; + args: string[]; +} { + let format: OutputFormat | null = null; + const remaining: string[] = []; + + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; + if (arg === undefined) continue; + + if (arg === "--json") { + format = "json"; + continue; + } + if (arg === "--pretty") { + format = "text"; + continue; + } + if (arg === "--minimal") { + format = "minimal"; + continue; + } + if (arg === "--format") { + const value = args[i + 1]; + if (value === "json" || value === "text" || value === "minimal") { + format = value; + i += 1; + continue; + } + } + + remaining.push(arg); + } + + if (format) { + return { format, args: remaining }; + } + + const envFormat = process.env["BEE_OUTPUT_FORMAT"]; + if (envFormat === "json" || envFormat === "text" || envFormat === "minimal") { + return { format: envFormat, args: remaining }; + } + + return { format: "text", args: remaining }; +} + +export function isJsonMode(format: OutputFormat): boolean { + return format === "json" || format === "minimal"; +}